| new file mode 100644 |
| index 000000000..0cda5876a |
| --- /dev/null |
| +++ b/internal/console/apicli_test.go |
| @@ -0,0 +1,165 @@ |
| +package console |
| + |
| +// The CLI's read-only API surface (apicli.go): `carlos apps` and `carlos |
| +// channels` reading through the console instead of the bucket directly. The |
| +// properties that matter, in the order they'd hurt if lost: a token only |
| +// ever sees ITS OWN memberships, an unknown account and one you don't belong |
| +// to are the identical 404, and a channel-ladder read mirrors the CLI's own |
| +// per-row ERROR behaviour rather than failing the whole response. |
| + |
| +import ( |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// getRaw is getJSON's plain-text cousin: some answers here (http.NotFound's |
| +// body) are not JSON, and asserting on them byte-for-byte is the point of |
| +// TestAPIChannelsRefusalMatrix. |
| +func getRaw(t *testing.T, u, bearer string) (int, string) { |
| + t.Helper() |
| + req, err := http.NewRequest(http.MethodGet, u, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if bearer != "" { |
| + req.Header.Set("Authorization", "Bearer "+bearer) |
| + } |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer res.Body.Close() |
| + body := make([]byte, 0, 256) |
| + buf := make([]byte, 256) |
| + for { |
| + n, err := res.Body.Read(buf) |
| + body = append(body, buf[:n]...) |
| + if err != nil { |
| + break |
| + } |
| + } |
| + return res.StatusCode, string(body) |
| +} |
| + |
| +// TestAPIAppsListsMemberships: a token only ever learns about the accounts |
| +// IT belongs to, and only that account's apps — a second account existing |
| +// (bac) never leaks into the body. |
| +func TestAPIAppsListsMemberships(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + // bac: a second account alice is NOT a member of. |
| + if err := fx.st.CreateAccount(2, "bac", "widgets", "bob@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "alice@keymail.dev", "code-apps-alice") |
| + |
| + status, out := getJSON(t, fx.ts.URL+"/api/cli/apps", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("apps = %d %v", status, out) |
| + } |
| + accts, _ := out["accounts"].([]any) |
| + if len(accts) != 1 { |
| + t.Fatalf("accounts = %v, want exactly bab (bac must not appear)", out["accounts"]) |
| + } |
| + a, _ := accts[0].(map[string]any) |
| + if str(t, a, "sqid") != "bab" || str(t, a, "name") != "ops" || str(t, a, "role") != "member" { |
| + t.Fatalf("account = %v", a) |
| + } |
| + apps, _ := a["apps"].([]any) |
| + found := false |
| + for _, v := range apps { |
| + if v == "titogo" { |
| + found = true |
| + } |
| + if v == "bac" { |
| + t.Fatalf("apps for bab leaked something from bac: %v", apps) |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("apps = %v, want titogo among them", apps) |
| + } |
| +} |
| + |
| +// TestAPIAppsNoToken: the same 401 cliUser always gives. |
| +func TestAPIAppsNoToken(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + status, out := getJSON(t, fx.ts.URL+"/api/cli/apps", "") |
| + if status != http.StatusUnauthorized { |
| + t.Fatalf("apps with no token = %d %v, want 401", status, out) |
| + } |
| + if !strings.Contains(str(t, out, "error"), "not signed in") { |
| + t.Fatalf("apps 401 body = %v, want the cliUser sentence", out) |
| + } |
| +} |
| + |
| +// TestAPIChannelsHappy: a promoted channel reads back through the API |
| +// exactly as the CLI's own `carlos channels` reads it — and the account |
| +// segment works identically whether given as the sqid or the name. |
| +func TestAPIChannelsHappy(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipVersion(t, fx, "titogo", "abc1234") |
| + seedChannel(t, fx, "titogo", "canary/x", "abc1234") |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-channels-paul") |
| + |
| + for _, seg := range []string{"bab", "ops"} { |
| + status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/"+seg+"/titogo/channels", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("channels (%s) = %d %v", seg, status, out) |
| + } |
| + chans, _ := out["channels"].([]any) |
| + if len(chans) != 1 { |
| + t.Fatalf("channels (%s) = %v, want exactly one entry", seg, out["channels"]) |
| + } |
| + entry, _ := chans[0].(map[string]any) |
| + if str(t, entry, "channel") != "canary/x" || str(t, entry, "version") != "abc1234" || |
| + str(t, entry, "rung") != "canary" || str(t, entry, "promoted_at") == "" { |
| + t.Fatalf("channels (%s) entry = %v", seg, entry) |
| + } |
| + } |
| +} |
| + |
| +// TestAPIChannelsRefusalMatrix: an unknown account, and an account you belong |
| +// to but an app it hasn't claimed, are the SAME 404 — byte-identical to |
| +// http.NotFound's own output, so a token holder learns nothing by probing. |
| +func TestAPIChannelsRefusalMatrix(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + |
| + // (a) member of nothing. |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-channels-eve") |
| + statusA, bodyA := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", eve) |
| + if statusA != http.StatusNotFound { |
| + t.Fatalf("member-of-nothing channels = %d, want 404", statusA) |
| + } |
| + |
| + // (b) member of bab, but the app is unclaimed there. |
| + if err := fx.st.AddMember(OpsAccountID, "frank@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + frank := cliToken(t, fx, "frank@keymail.dev", "code-channels-frank") |
| + statusB, bodyB := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/ghost-app/channels", frank) |
| + if statusB != http.StatusNotFound { |
| + t.Fatalf("unclaimed-app channels = %d, want 404", statusB) |
| + } |
| + |
| + if bodyA != bodyB { |
| + t.Fatalf("the two refusals are not byte-identical:\n(a) %q\n(b) %q", bodyA, bodyB) |
| + } |
| + // http.NotFound's own, well-known output — pinned so a future change to |
| + // either handler is caught even if it stays self-consistent. |
| + if bodyA != "404 page not found\n" { |
| + t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", bodyA) |
| + } |
| +} |