| index 60afc5b60..464f0c472 100644 |
| --- a/internal/console/apicli_test.go |
| +++ b/internal/console/apicli_test.go |
| @@ -289,13 +289,22 @@ func TestAPIChannelsRefusalMatrix(t *testing.T) { |
| if bodyA != "404 page not found\n" { |
| t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", bodyA) |
| } |
| + |
| + // (c) the same probe carrying ?exact=sqid — the -account-id transport is |
| + // not a second, chattier door: a non-member meets the identical refusal |
| + // whether they name the account exactly or loosely. |
| + statusC, bodyC := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels?exact=sqid", eve) |
| + if statusC != http.StatusNotFound || bodyC != bodyA { |
| + t.Fatalf("exact=sqid member-of-nothing = %d %q, want the identical 404 %q", statusC, bodyC, bodyA) |
| + } |
| } |
| |
| -// TestAPIAccountSqidBeatsName: a SQID match wins outright, even when the |
| -// segment given also happens to equal ANOTHER of the caller's accounts' |
| -// NAME. Picking that other account here would list (or later, act on) the |
| -// wrong tenant. |
| -func TestAPIAccountSqidBeatsName(t *testing.T) { |
| +// TestAPIAccountSqidNameCollisionRefused: resolution is SYMMETRIC — a |
| +// segment matching one account's SQID and another's NAME is ambiguous, not a |
| +// sqid win. Precedence is how a reference silently changes meaning the day a |
| +// colliding account appears; here the API refuses and names the flag that |
| +// settles it, and -account-id (?exact=sqid) then answers unambiguously. |
| +func TestAPIAccountSqidNameCollisionRefused(t *testing.T) { |
| fx := newConsoleFixture(t) |
| now := time.Now() |
| // bac's NAME is literally "bab" — the sqid of the ops account. Alice |
| @@ -309,19 +318,28 @@ func TestAPIAccountSqidBeatsName(t *testing.T) { |
| if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| t.Fatal(err) |
| } |
| - token := cliToken(t, fx, "alice@keymail.dev", "code-sqid-beats-name") |
| + token := cliToken(t, fx, "alice@keymail.dev", "code-sqid-name-collision") |
| |
| - // "bab" as the account segment must resolve to the SQID match (the ops |
| - // account, which has titogo) and not the NAME match (bac, which doesn't). |
| status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", token) |
| + if status != http.StatusConflict { |
| + t.Fatalf("collision channels = %d %v, want 409 (bab is both a sqid and a name here)", status, out) |
| + } |
| + msg := str(t, out, "error") |
| + if !strings.Contains(msg, "bab") || !strings.Contains(msg, "bac") || !strings.Contains(msg, "-account-id") { |
| + t.Fatalf("collision error = %q, want both candidates and -account-id named", msg) |
| + } |
| + |
| + // -account-id settles it: as a sqid, "bab" is the ops account alone. |
| + status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels?exact=sqid", token) |
| if status != http.StatusOK { |
| - t.Fatalf("channels = %d %v, want 200 (sqid match, not bac's name match)", status, out) |
| + t.Fatalf("exact=sqid channels = %d %v, want 200 (the ops account, which holds titogo)", status, out) |
| } |
| } |
| |
| // TestAPIAccountAmbiguousNameRefused: two of the caller's own accounts |
| -// sharing a name is refused with 409 and both candidate sqids named, never |
| -// silently resolved to one of them. |
| +// sharing a name is refused with 409 and both candidates named — never |
| +// silently resolved to one of them — and the refusal names -account-id, |
| +// which is the only thing the caller can do about it. |
| func TestAPIAccountAmbiguousNameRefused(t *testing.T) { |
| fx := newConsoleFixture(t) |
| now := time.Now() |
| @@ -342,6 +360,60 @@ func TestAPIAccountAmbiguousNameRefused(t *testing.T) { |
| if !strings.Contains(msg, sqidA) || !strings.Contains(msg, sqidB) { |
| t.Fatalf("ambiguous-name error = %q, want both %q and %q named", msg, sqidA, sqidB) |
| } |
| + if !strings.Contains(msg, "-account-id") { |
| + t.Fatalf("ambiguous-name error = %q, want it to name the -account-id flag", msg) |
| + } |
| +} |
| + |
| +// TestAPIAccountExactSqid: the ?exact=sqid contract the CLI's -account-id |
| +// flag rides on. It matches the segment as a SQID only — a name, however |
| +// unique to this caller, never answers — so the reference a person reaches |
| +// for when the pretty one is ambiguous can never itself become ambiguous. |
| +func TestAPIAccountExactSqid(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + sqidA, sqidB := domains.Sqid(2), domains.Sqid(3) |
| + // Two of alice's own accounts named "amadan"; only the first holds web. |
| + if err := fx.st.CreateAccount(2, sqidA, "amadan", "alice@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.CreateAccount(3, sqidB, "amadan", "alice@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.CreateAppIn(2, "web", "alice@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "alice@keymail.dev", "code-exact-sqid") |
| + |
| + // The bare name is ambiguous — and says so, in the words a caller can act on. |
| + status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/amadan/web/channels", token) |
| + if status != http.StatusConflict { |
| + t.Fatalf("ambiguous amadan = %d %v, want 409", status, out) |
| + } |
| + msg := str(t, out, "error") |
| + if !strings.Contains(msg, sqidA) || !strings.Contains(msg, sqidB) || !strings.Contains(msg, "-account-id") { |
| + t.Fatalf("ambiguous error = %q, want both sqids and -account-id named", msg) |
| + } |
| + |
| + // The sqid, exactly, resolves to the account that holds the app. |
| + status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/"+sqidA+"/web/channels?exact=sqid", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("exact=sqid %s = %d %v, want 200", sqidA, status, out) |
| + } |
| + |
| + // A NAME under exact=sqid does not match at all — the same 404 an unknown |
| + // account gets, byte-identical to http.NotFound's own output. |
| + st, body := getRaw(t, fx.ts.URL+"/api/cli/apps/amadan/web/channels?exact=sqid", token) |
| + if st != http.StatusNotFound || body != "404 page not found\n" { |
| + t.Fatalf("exact=sqid with a name = %d %q, want the stdlib 404", st, body) |
| + } |
| + |
| + // The caller's OTHER account, named exactly, simply hasn't claimed web: |
| + // the identical 404, so exact mode leaks nothing either. |
| + st, body2 := getRaw(t, fx.ts.URL+"/api/cli/apps/"+sqidB+"/web/channels?exact=sqid", token) |
| + if st != http.StatusNotFound || body2 != body { |
| + t.Fatalf("exact=sqid unclaimed app = %d %q, want the identical 404 %q", st, body2, body) |
| + } |
| } |
| |
| // TestAPIAccountNameMatchIsCaseInsensitive: a unique name match applies |