Files
pad/internal/cli/collection_alias_test.go
xarmian bc68b84848 fix(cli,mcp): send raw collection slug so an alias can't shadow a real collection (BUG-2630) (#1162)
* fix(cli,mcp): send raw collection slug so an alias can't shadow a real collection (BUG-2630)

The client-side alias map (collections.NormalizeSlug) rewrote seven hardcoded
singulars ("task", "plan", …) to their plurals BEFORE the request. In a
workspace whose collection slug IS one of those singulars, the user's exact
name was rewritten away and their create/list/move landed in a DIFFERENT
collection — silently, with a success message naming the wrong one.

Fix, per lead ruling on the BUG-2630 trail, split by transport:

CLI (real HTTP, may hit a pre-resolver server) — Option 2, one shared helper
cli.WithCollectionAliasFallback: send the RAW slug first (the server's
exact-match-first resolver from BUG-2578 wins, so an exact name is never
shadowed), and retry with the alias ONLY on a collection-not-found error, only
when the alias differs. Keying on collection-not-found is load-bearing: a
request to a collection that exists but fails for another reason is never
retried into the alias (that would recreate the bug). Both the schema fetch and
the create funnel through the helper so typed --field values parse against — and
the item lands in — one collection. On a genuine double-miss the error names the
RAW slug the user typed (collection "widget" not found), not the alias.

MCP remote transport (in-process ServeHTTP against the SAME binary, which always
carries the resolver — no version skew) — drop client-side normalization
entirely and send raw. Also removed the dormant expandPath collection
normalization: no routeSpec uses a {collection}/{target_collection} path
placeholder, so the branch was dead code in the area this fixes.

Search is deliberately out of scope (filed BUG-2659): its collection is a global
c.slug=? FILTER, not a path — a miss returns 200 + zero results, not
collection-not-found, so the retry can't key on it; and handleSearch is
cross-workspace, so the per-workspace resolver has no single workspace to run
against. Cross-workspace copy is excluded too (DR-13 forbids auto-retrying the
copy mutation).

Verified live against a real server: create/list/move into a singular collection
that collides with its plural now land in the named singular; shorthand still
resolves; genuine misses error naming the raw slug. New MCP integration test
reproduces the original shadow (item → PLANS-1) when normalization is restored.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* fix(server,cli): own collection resolution server-side + capability-gate the CLI retry (BUG-2630 Codex r1)

Addresses all three Codex round-1 findings, via the lead's ruling that
dissolves the earlier "retry vs archived-protection" tension by making the
server the sole owner of resolution semantics.

Finding #2 (MCP lost the legacy abbreviations t/i/p/d and phase/phases -> plans,
which the server's ±s resolver did not cover): fold the legacy alias map into
collectionSlugCandidates as a LAST-resort candidate. Exact-match-first and the
archived-claims refusal run for the input and every structural candidate before
the alias is reached, so it never shadows or redirects around a real/archived
collection. Now every client can send the raw slug — including the MCP transport
that can't retry — and lose nothing.

Finding #1 (the client retry re-opened the archived/hidden redirect the server
deliberately refused, because not_found can't be told from absent): add a
collection_resolution capability flag to GET /server/capabilities and gate the
CLI retry on it. Happy path unchanged (raw slug, one request). On
collection-not-found ONLY, the client probes capabilities once (cached): if the
server advertises resolution, its not-found is authoritative — the slug is
absent, archived, or hidden — so the client does NOT retry. Only an older server
that lacks the flag (or 404s the endpoint) triggers the legacy alias retry,
which is non-regressive there since old servers never had the protection. The
probe fails safe toward retry. This makes the follow-up distinct-error-code bug
unnecessary.

Finding #3 (double-fail masked a substantive alias error as "collection not
found"): the helper now surfaces a substantive alias-attempt error verbatim, and
only collapses to the raw-named not-found when the alias ALSO 404s.

Verified live against a resolving server: create/list/move into a singular that
collides with its plural land in the named singular; the abbreviation `i`
resolves to `ideas`; and after archiving `plan`, `create plan` honestly fails
("collection \"plan\" not found") instead of being retried into a live `plans`.
Gates: make lint 0 issues; go test ./... green; make test-pg green.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* fix(cli): fail-closed capability probe + always-retry the schema lookup (BUG-2630 Codex r2)

P1: the capability probe cached ANY failure as "no resolver", so a single
transient blip (timeout/5xx) permanently re-enabled the alias retry and could
bypass the archived/hidden protection on a resolving server. Now the probe
distinguishes a DEFINITIVE verdict (HTTP 200 with the flag, or a clean 404 =
legacy build) from an INDETERMINATE one (transport error / 5xx): only definitive
verdicts are cached, and an indeterminate probe fails CLOSED (trusts the
not-found, no retry) without caching, so the next call re-probes. A genuine old
server still returns a clean 404, so its retry is unaffected. Renamed the
predicate to CollectionNotFoundIsAuthoritative to name what it actually decides.

P2: the create schema lookup hits exact-match-only GetCollection, which does NOT
resolve slugs server-side, so capability-gating it made `create task
--field amount=3` 404 the schema fetch, skip the retry, and send amount as the
string "3". The schema lookup now always retries the alias (nil gate),
restoring typed-field parsing against an aliased collection's schema. Best-effort
as before: a genuine miss still degrades to string fields.

New client test covers the probe: definitive verdicts cache (one probe), and a
transient failure fails closed AND re-probes on the next call (mutation-verified).

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* docs(cli): note fail-closed-on-indeterminate as a deliberate safety asymmetry (BUG-2630)

Per lead review: make explicit in CollectionNotFoundIsAuthoritative's doc that
failing closed on an indeterminate capability probe is deliberate — a recoverable
alias-shorthand miss is the safer side of the trade vs a retry doing an
un-undoable wrong-write. Comment-only.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-19 08:13:29 -04:00

225 lines
8.2 KiB
Go

package cli
import (
"errors"
"fmt"
"testing"
)
// notFoundCollErr is the create/list wire shape for a missing collection.
func notFoundCollErr() error {
return &APIError{Code: "not_found", Message: "Collection not found"}
}
// invalidCollErr is the move wire shape for a missing target collection.
func invalidCollErr() error {
return &APIError{Code: "invalid_collection", Message: "Target collection not found"}
}
func TestWithCollectionAliasFallback_RawSucceeds_NoRetry(t *testing.T) {
// The shadow case (BUG-2630): the user typed a slug that IS a real
// collection ("plan") whose alias ("plans") also exists. The raw attempt
// succeeds, so the fallback must NOT retry — retrying would route the user
// to the aliased collection they did not name.
var calls []string
got, err := WithCollectionAliasFallback("plan", nil, func(slug string) (string, error) {
calls = append(calls, slug)
return "landed:" + slug, nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "landed:plan" {
t.Fatalf("got %q, want landed:plan", got)
}
if len(calls) != 1 || calls[0] != "plan" {
t.Fatalf("expected exactly one call with the raw slug, got %v", calls)
}
}
func TestWithCollectionAliasFallback_CollectionNotFound_RetriesAlias(t *testing.T) {
// Old-server compat: raw "task" 404s because the collection is "tasks" and
// the server has no resolver. The fallback retries with the alias.
var calls []string
got, err := WithCollectionAliasFallback("task", nil, func(slug string) (string, error) {
calls = append(calls, slug)
if slug == "task" {
return "", notFoundCollErr()
}
return "landed:" + slug, nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "landed:tasks" {
t.Fatalf("got %q, want landed:tasks", got)
}
if len(calls) != 2 || calls[0] != "task" || calls[1] != "tasks" {
t.Fatalf("expected raw then alias, got %v", calls)
}
}
func TestWithCollectionAliasFallback_InvalidCollectionCode_RetriesAlias(t *testing.T) {
// The move path reports a missing target collection as invalid_collection.
var calls []string
_, err := WithCollectionAliasFallback("task", nil, func(slug string) (string, error) {
calls = append(calls, slug)
if slug == "task" {
return "", invalidCollErr()
}
return "ok", nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(calls) != 2 {
t.Fatalf("expected a retry on invalid_collection, got %v", calls)
}
}
func TestWithCollectionAliasFallback_NonAliasedInput_NoRetry(t *testing.T) {
// A slug that NormalizeSlug leaves unchanged ("widgets") has no alias to
// try, so a not-found must return immediately without a second call.
var calls int
_, err := WithCollectionAliasFallback("widgets", nil, func(slug string) (string, error) {
calls++
return "", notFoundCollErr()
})
if calls != 1 {
t.Fatalf("expected exactly one call (no alias to retry), got %d", calls)
}
// The error the user sees names the slug they typed (constraint 2), with
// the collection-not-found Code preserved for downstream inspection.
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.Code != "not_found" {
t.Fatalf("expected a *APIError with Code=not_found, got %v", err)
}
if apiErr.Message != `collection "widgets" not found` {
t.Fatalf("error should name the raw slug, got %q", apiErr.Message)
}
}
func TestWithCollectionAliasFallback_NonCollectionError_NoRetry(t *testing.T) {
// Constraint 1 (lead): a request aimed at a collection that really exists
// ("plan") but that fails for a DIFFERENT reason (here a validation error)
// must NOT be retried against the alias ("plans"). Retrying could silently
// succeed against the wrong collection — BUG-2630 in a new costume.
var calls []string
sentinel := &APIError{Code: "bad_request", Message: "Title is required"}
_, err := WithCollectionAliasFallback("plan", nil, func(slug string) (string, error) {
calls = append(calls, slug)
return "", sentinel
})
if !errors.Is(err, sentinel) {
t.Fatalf("expected the raw sentinel error back, got %v", err)
}
if len(calls) != 1 || calls[0] != "plan" {
t.Fatalf("a non-collection error must not trigger the alias retry, got %v", calls)
}
}
func TestWithCollectionAliasFallback_BothFail_SurfacesRawError(t *testing.T) {
// Constraint 2 (lead): when both raw and alias fail, the error the user
// sees must name the slug they typed ("task"), not the alias tried on their
// behalf ("tasks").
got := "sentinel-unset"
_, err := WithCollectionAliasFallback("task", nil, func(slug string) (string, error) {
got = slug
if slug == "task" {
return "", notFoundCollErr()
}
return "", &APIError{Code: "not_found", Message: "Collection not found"}
})
if got != "tasks" {
t.Fatalf("expected the alias to have been tried, last slug was %q", got)
}
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected a *APIError, got %v", err)
}
if apiErr.Message != `collection "task" not found` {
t.Fatalf("double-fail error must name the RAW slug, got %q", apiErr.Message)
}
}
func TestWithCollectionAliasFallback_AliasSubstantiveError_Surfaced(t *testing.T) {
// Codex P2: when the raw slug misses but the alias names a REAL collection
// that fails for a substantive reason (here a plan limit), the user must
// see THAT error, not a misleading "collection not found" — the alias
// collection exists, so "not found" would be a lie.
planLimit := &APIError{Code: "plan_limit_exceeded", Message: "item limit reached"}
_, err := WithCollectionAliasFallback("task", nil, func(slug string) (string, error) {
if slug == "task" {
return "", notFoundCollErr()
}
return "", planLimit // the "tasks" collection exists but is over limit
})
if !errors.Is(err, planLimit) {
t.Fatalf("expected the substantive alias error surfaced, got %v", err)
}
}
func TestWithCollectionAliasFallback_ServerResolves_SuppressesRetry(t *testing.T) {
// BUG-2630 #1: a server that resolves collections itself has already tried
// the alias AND enforced exact-match + the archived/hidden refusal, so its
// collection-not-found is AUTHORITATIVE. The client must NOT retry the alias
// (which would defeat that protection, e.g. redirect an archived `plan` into
// a live `plans`).
var calls []string
_, err := WithCollectionAliasFallback("plan", func() bool { return true }, func(slug string) (string, error) {
calls = append(calls, slug)
return "", notFoundCollErr()
})
if len(calls) != 1 || calls[0] != "plan" {
t.Fatalf("a resolving server's not-found must not be retried, got %v", calls)
}
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.Message != `collection "plan" not found` {
t.Fatalf("expected raw-named not-found, got %v", err)
}
}
func TestWithCollectionAliasFallback_ServerLacksResolution_Retries(t *testing.T) {
// The other branch: an OLD server that does not advertise resolution never
// had the archived-claims protection, so the legacy alias retry runs and is
// non-regressive there.
var calls []string
got, err := WithCollectionAliasFallback("plan", func() bool { return false }, func(slug string) (string, error) {
calls = append(calls, slug)
if slug == "plan" {
return "", notFoundCollErr()
}
return "landed:" + slug, nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "landed:plans" || len(calls) != 2 {
t.Fatalf("expected retry into plans, got %q calls=%v", got, calls)
}
}
func TestIsCollectionNotFound(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{"create/list not_found", notFoundCollErr(), true},
{"move invalid_collection", invalidCollErr(), true},
{"item not_found (different message)", &APIError{Code: "not_found", Message: "item TASK-9 not found"}, false},
{"validation error", &APIError{Code: "bad_request", Message: "Title is required"}, false},
{"plan limit", &APIError{Code: "plan_limit_exceeded", Message: "limit"}, false},
{"wrapped not_found", fmt.Errorf("ctx: %w", notFoundCollErr()), true},
{"non-APIError", errors.New("network down"), false},
{"nil", nil, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isCollectionNotFound(tc.err); got != tc.want {
t.Fatalf("isCollectionNotFound(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}