| index aa347a4e4..56baa749e 100644 |
| --- a/internal/console/apicli_test.go |
| +++ b/internal/console/apicli_test.go |
| @@ -9,7 +9,10 @@ package console |
| // ERROR behaviour rather than failing the whole response. |
| |
| import ( |
| + "bytes" |
| "context" |
| + "encoding/json" |
| + "io" |
| "net/http" |
| "strings" |
| "testing" |
| @@ -48,6 +51,35 @@ func getRaw(t *testing.T, u, bearer string) (int, string) { |
| return res.StatusCode, string(body) |
| } |
| |
| +// postRaw is postJSON's byte-exact cousin: TestAPIPromoteStableRefused needs |
| +// the raw response body, not a decoded map, to assert two responses are |
| +// IDENTICAL rather than merely equal key-by-key. |
| +func postRaw(t *testing.T, u string, body any, bearer string) (int, string) { |
| + t.Helper() |
| + raw, err := json.Marshal(body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(raw)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Header.Set("Content-Type", "application/json") |
| + if bearer != "" { |
| + req.Header.Set("Authorization", "Bearer "+bearer) |
| + } |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer res.Body.Close() |
| + out, err := io.ReadAll(res.Body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return res.StatusCode, string(out) |
| +} |
| + |
| // TestAPIAppsListsMemberships: a token only ever learns about the accounts |
| // IT belongs to, and only that account's apps — a second account's OWN app |
| // (secretapp, claimed in bac) never leaks into bab's list. |
| @@ -325,3 +357,247 @@ func TestAPIAccountNameMatchIsCaseInsensitive(t *testing.T) { |
| t.Fatalf("case-insensitive name match = %d %v, want 200", status, out) |
| } |
| } |
| + |
| +// ---- promote + rollback (apicli.go) ----------------------------------------- |
| +// |
| +// The CLI's write doors: `carlos promote` and `carlos rollback` through the |
| +// same browser doors as the console UI (handlePromote/handleRollback, |
| +// server.go), minus CSRF (a JSON bearer request has no session cookie to |
| +// forge) and minus the passkey step-up (there is no browser here to redirect |
| +// into Keymail) — stable is refused outright instead. |
| + |
| +// TestAPIPromoteHappy: a shipped version reaches a fresh channel, the pointer |
| +// reads back the new version, and the audit trail names it a CLI action. |
| +func TestAPIPromoteHappy(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") |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-promote-happy") |
| + |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", |
| + map[string]string{"version": "abc1234", "channel": "canary/x"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("promote = %d %v", status, out) |
| + } |
| + if str(t, out, "result") != "promoted" || str(t, out, "to") != "canary" { |
| + t.Fatalf("promote body = %v", out) |
| + } |
| + if v, present := out["noop"]; !present || v != false { |
| + t.Fatalf("promote noop = %v (present=%v), want false", v, present) |
| + } |
| + cp := fx.channelVersion(t, "titogo", "canary/x") |
| + if cp.Version != "abc1234" { |
| + t.Fatalf("channel after promote = %q, want abc1234", cp.Version) |
| + } |
| + events, err := fx.st.Events(OpsAccountID, "titogo", 10) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + found := false |
| + for _, e := range events { |
| + if e.Action == "promote" && strings.Contains(e.Detail, "(cli)") { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("events = %v, want a promote row whose detail names the CLI", events) |
| + } |
| +} |
| + |
| +// TestAPIPromoteStableRefused: stable cannot be reached over the API at all — |
| +// there is no passkey to step up to — and the refusal is byte-identical for a |
| +// member token and an owner token, so a token holder learns nothing about |
| +// their own role from this door (spec §3). |
| +func TestAPIPromoteStableRefused(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") |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + owner := cliToken(t, fx, "paul@keymail.dev", "code-stable-owner") |
| + member := cliToken(t, fx, "alice@keymail.dev", "code-stable-member") |
| + |
| + body := map[string]string{"version": "abc1234", "channel": "stable"} |
| + statusO, outO := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", body, owner) |
| + statusM, outM := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", body, member) |
| + if statusO != http.StatusForbidden || statusM != http.StatusForbidden { |
| + t.Fatalf("stable promote = owner %d, member %d, want both 403", statusO, statusM) |
| + } |
| + if outO != outM { |
| + t.Fatalf("stable refusal not byte-identical:\nowner: %q\nmember: %q", outO, outM) |
| + } |
| + wantURL := fx.ts.URL + "/a/bab/apps/titogo" |
| + if !strings.Contains(outO, wantURL) { |
| + t.Fatalf("stable refusal = %q, want console_url %q", outO, wantURL) |
| + } |
| + if !strings.Contains(outO, "passkey") { |
| + t.Fatalf("stable refusal = %q, want it to name the passkey requirement", outO) |
| + } |
| +} |
| + |
| +// TestAPIPromoteNoKey: a read-only deployment (no signing key) refuses every |
| +// promotion with 503, the same as the browser door. |
| +func TestAPIPromoteNoKey(t *testing.T) { |
| + fx := newConsoleFixtureKeyed(t, false) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-promote-nokey") |
| + |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", |
| + map[string]string{"version": "abc1234", "channel": "canary/x"}, token) |
| + if status != http.StatusServiceUnavailable { |
| + t.Fatalf("promote with no key = %d %v, want 503", status, out) |
| + } |
| +} |
| + |
| +// TestAPIPromoteBadVersion: a version that fails release.ValidVersion is a |
| +// 400 naming the problem, before anything touches the release store. |
| +func TestAPIPromoteBadVersion(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) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-promote-badversion") |
| + |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", |
| + map[string]string{"version": "../etc/passwd", "channel": "canary/x"}, token) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("bad version promote = %d %v, want 400", status, out) |
| + } |
| + if !strings.Contains(str(t, out, "error"), "version") { |
| + t.Fatalf("bad version error = %q, want it to name the version", str(t, out, "error")) |
| + } |
| +} |
| + |
| +// TestAPIPromoteBadChannel: an invalid channel name is a 400 naming the |
| +// problem. |
| +func TestAPIPromoteBadChannel(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") |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-promote-badchannel") |
| + |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", |
| + map[string]string{"version": "abc1234", "channel": "bad channel"}, token) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("bad channel promote = %d %v, want 400", status, out) |
| + } |
| + if !strings.Contains(str(t, out, "error"), "channel") { |
| + t.Fatalf("bad channel error = %q, want it to name the channel", str(t, out, "error")) |
| + } |
| +} |
| + |
| +// TestAPIPromoteUnshippedVersion: a well-formed version that was never |
| +// shipped is a 404 naming the app, the same sentence the browser door uses. |
| +func TestAPIPromoteUnshippedVersion(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) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-promote-unshipped") |
| + |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", |
| + map[string]string{"version": "ghost999", "channel": "canary/x"}, token) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("unshipped version promote = %d %v, want 404", status, out) |
| + } |
| + if !strings.Contains(str(t, out, "error"), "not a shipped release") { |
| + t.Fatalf("unshipped version error = %q, want the browser door's sentence", str(t, out, "error")) |
| + } |
| +} |
| + |
| +// TestAPIPromoteNoHotfix: the request struct has no hotfix field at all — |
| +// encoding/json silently ignores the unknown key, and the ladder gate still |
| +// refuses a skip exactly as it would with no such key present. There is no |
| +// way, from this door, to bypass the ladder. |
| +func TestAPIPromoteNoHotfix(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") |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-promote-nohotfix") |
| + |
| + // "beta/x" is on no rung yet (nothing has promoted it there), so this is |
| + // the ladder's plain refusal, sent alongside a hotfix:true the handler |
| + // must never act on. |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", |
| + map[string]any{"version": "abc1234", "channel": "beta/x", "hotfix": true}, token) |
| + if status != http.StatusConflict { |
| + t.Fatalf("ladder-skip promote with hotfix:true = %d %v, want 409 (hotfix must be inert)", status, out) |
| + } |
| + if !strings.Contains(str(t, out, "error"), "rung") { |
| + t.Fatalf("ladder-skip error = %q, want the ladder's own refusal text", str(t, out, "error")) |
| + } |
| + // And the pointer must not exist — the promotion truly never happened. |
| + if _, err := release.Channel(context.Background(), fx.rel, fx.pub, release.OpsAccount, "titogo", "beta/x"); err == nil { |
| + t.Fatalf("beta/x channel exists after a refused promotion, want it absent") |
| + } |
| +} |
| + |
| +// TestAPIRollbackHappy: two promotions to the same channel, then a rollback, |
| +// return the pointer to the first version. |
| +func TestAPIRollbackHappy(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") |
| + shipVersion(t, fx, "titogo", "def5678") |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-rollback-happy") |
| + |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", |
| + map[string]string{"version": "abc1234", "channel": "canary/x"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("first promote = %d %v", status, out) |
| + } |
| + status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", |
| + map[string]string{"version": "def5678", "channel": "canary/x"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("second promote = %d %v", status, out) |
| + } |
| + if cp := fx.channelVersion(t, "titogo", "canary/x"); cp.Version != "def5678" { |
| + t.Fatalf("channel before rollback = %q, want def5678", cp.Version) |
| + } |
| + |
| + status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/rollback", |
| + map[string]string{"channel": "canary/x"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("rollback = %d %v", status, out) |
| + } |
| + if str(t, out, "result") != "rolled back" { |
| + t.Fatalf("rollback body = %v", out) |
| + } |
| + if cp := fx.channelVersion(t, "titogo", "canary/x"); cp.Version != "abc1234" { |
| + t.Fatalf("channel after rollback = %q, want abc1234 (back to the first version)", cp.Version) |
| + } |
| + events, err := fx.st.Events(OpsAccountID, "titogo", 10) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + found := false |
| + for _, e := range events { |
| + if e.Action == "rollback" && strings.Contains(e.Detail, "(cli)") { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("events = %v, want a rollback row whose detail names the CLI", events) |
| + } |
| +} |