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, account resolution never guesses between // candidates, 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 ( "bytes" "context" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "mime/multipart" "net/http" "net/url" "os" "os/exec" "path/filepath" "reflect" "strings" "sync" "testing" "time" "github.com/carlosframework/platform/internal/caps" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/release" ) // 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) } // 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. func TestAPIAppsListsMemberships(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() // bac: a second account alice is NOT a member of, with its own app. if err := fx.st.CreateAccount(2, "bac", "widgets", "bob@keymail.dev", now); err != nil { t.Fatal(err) } if _, err := fx.st.CreateAppIn(2, "secretapp", "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 == "secretapp" { t.Fatalf("bab's apps leaked bac's app: %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 — the account segment // works identically whether given as the sqid or the name, `rung` is always // present (empty for a channel outside the ladder's vocabulary), and // `promoted_at` is a real RFC3339 timestamp a typed client can parse. 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") // A second, non-rung channel — "adhoc" matches no ladder rung, so `rung` // must come back "" rather than being omitted. shipVersion(t, fx, "titogo", "def5678") seedChannel(t, fx, "titogo", "adhoc", "def5678") 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) != 2 { t.Fatalf("channels (%s) = %v, want exactly two entries", seg, out["channels"]) } byName := map[string]map[string]any{} for _, c := range chans { m, _ := c.(map[string]any) byName[str(t, m, "channel")] = m } canary, ok := byName["canary/x"] if !ok { t.Fatalf("channels (%s) missing canary/x: %v", seg, chans) } if str(t, canary, "version") != "abc1234" || str(t, canary, "rung") != "canary" { t.Fatalf("canary/x entry (%s) = %v", seg, canary) } if _, err := time.Parse(time.RFC3339, str(t, canary, "promoted_at")); err != nil { t.Fatalf("canary/x promoted_at (%s) = %q, not RFC3339: %v", seg, str(t, canary, "promoted_at"), err) } adhoc, ok := byName["adhoc"] if !ok { t.Fatalf("channels (%s) missing adhoc: %v", seg, chans) } if str(t, adhoc, "version") != "def5678" { t.Fatalf("adhoc entry (%s) = %v", seg, adhoc) } if v, present := adhoc["rung"]; !present || v != "" { t.Fatalf("adhoc rung (%s) = %v (present=%v), want present and empty", seg, v, present) } if _, err := time.Parse(time.RFC3339, str(t, adhoc, "promoted_at")); err != nil { t.Fatalf("adhoc promoted_at (%s) = %q, not RFC3339: %v", seg, str(t, adhoc, "promoted_at"), err) } } } // TestAPIChannelsErrorRow: a channel pointer that fails verification becomes // one erroring ROW, not a failed response — the good channel alongside it // must still read back intact. func TestAPIChannelsErrorRow(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") // A garbage pointer at a second channel key — not signed, not even JSON — // planted directly in the bucket the way a corrupted or hand-edited // object would arrive. garbage := []byte("not a valid signed channel pointer") if _, err := fx.rel.Put(context.Background(), release.ChannelKey(release.OpsAccount, "titogo", "broken"), garbage, ""); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-channels-error-row") status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", token) if status != http.StatusOK { t.Fatalf("channels = %d %v, want 200 even with one bad pointer", status, out) } chans, _ := out["channels"].([]any) if len(chans) != 2 { t.Fatalf("channels = %v, want exactly two rows (one good, one broken)", out["channels"]) } byName := map[string]map[string]any{} for _, c := range chans { m, _ := c.(map[string]any) byName[str(t, m, "channel")] = m } broken, ok := byName["broken"] if !ok { t.Fatalf("channels missing the broken row: %v", chans) } if str(t, broken, "error") == "" { t.Fatalf("broken row = %v, want a non-empty error", broken) } if _, present := broken["version"]; present { t.Fatalf("broken row = %v, want no version alongside the error", broken) } good, ok := byName["canary/x"] if !ok { t.Fatalf("channels missing the good row: %v", chans) } if str(t, good, "version") != "abc1234" || str(t, good, "rung") != "canary" { t.Fatalf("good row = %v, want it unaffected by the broken one", good) } } // TestAPIChannelsPipelineEnvelope pins the additive "pipeline_entry"/ // "pipeline_channels" envelope keys (release pipelines v2, Task 11, review // fix round 1): a pipelined app's channels response carries the REDUCED view // as two flat top-level keys — "pipeline_entry" and "pipeline_channels": // [names in order] — sharing release.GetPipeline's read with // handleAPIPipelineGet (Task 6) so the two doors can never disagree. They // are deliberately NOT nested under a "pipeline" key: that JSON path already // names the full release.Pipeline object on the `/pipeline` door, and // reusing it here for a differently-shaped value would mean the same key // meaning two incompatible things depending on which door answered. // // A legacy app (no pipeline.json) gets BOTH keys OMITTED, never present as // null — this door does not attempt to distinguish "no pipeline" from "the // console could not read one" (see TestAPIChannelsPipelineUnreadableOmitsKey); // that distinction belongs to the dedicated `/pipeline` door alone. Old CLIs // decode "channels" and ignore the rest either way, so both shapes are // additive. func TestAPIChannelsPipelineEnvelope(t *testing.T) { fx := newConsoleFixture(t) token := cliToken(t, fx, "paul@keymail.dev", "code-channels-pipeline") // Pipelined app: created through the API door, which seeds the // one-channel edge default (TestAPIPipelineRoundTripAndDefaults), then // widened to two channels so entry != the app's only channel. status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "piped"}, token) if status != http.StatusOK { t.Fatalf("create app = %d %v", status, out) } status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/piped/pipeline", map[string]any{"channels": []map[string]any{{"name": "edge"}, {"name": "production"}}}, token) if status != http.StatusOK { t.Fatalf("pipeline put = %d %v", status, out) } status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/piped/channels", token) if status != http.StatusOK { t.Fatalf("channels (pipelined) = %d %v", status, out) } if str(t, out, "pipeline_entry") != "edge" { t.Fatalf("pipeline_entry = %v, want edge", out["pipeline_entry"]) } names, _ := out["pipeline_channels"].([]any) if len(names) != 2 || names[0] != "edge" || names[1] != "production" { t.Fatalf("pipeline_channels = %v, want [edge production]", names) } // The channel-rows key is untouched: a fresh app with no promotions has // none yet. chans, _ := out["channels"].([]any) if len(chans) != 0 { t.Fatalf("channels (pipelined, unpromoted) = %v, want none yet", chans) } // Legacy app: created directly, bypassing the API create door's // default-pipeline write — no pipeline.json exists at all, so BOTH keys // must be absent, together, exactly as the unreadable case below. if _, err := fx.st.CreateAppIn(OpsAccountID, "legacyapp", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/legacyapp/channels", token) if status != http.StatusOK { t.Fatalf("channels (legacy) = %d %v", status, out) } if _, present := out["pipeline_entry"]; present { t.Fatalf("channels (legacy) pipeline_entry = %v, want the key OMITTED", out["pipeline_entry"]) } if _, present := out["pipeline_channels"]; present { t.Fatalf("channels (legacy) pipeline_channels = %v, want the key OMITTED", out["pipeline_channels"]) } } // TestAPIChannelsPipelineUnreadableOmitsKey: an app whose pipeline.json // exists but fails verification gets the SAME wire shape as a legacy app — // both pipeline_entry/pipeline_channels OMITTED. Unlike the dedicated // `/pipeline` door (which answers a genuinely-missing pipeline with 200/null // and an unreadable one with 502, Task 6), this door does not attempt that // distinction at all (review fix round 1) — it is a convenience summary for // deploy resolution, and an absent entry channel is the correct conservative // answer whether the pipeline is missing or merely unreadable. The channels // listing itself is unharmed: it is read-only info this door must not fail // over a pipeline it doesn't strictly need. func TestAPIChannelsPipelineUnreadableOmitsKey(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } if _, err := fx.rel.Put(context.Background(), release.PipelineKey("bab", "widget"), []byte("not json"), ""); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-channels-pipeline-unreadable") status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/channels", token) if status != http.StatusOK { t.Fatalf("channels (unreadable pipeline) = %d %v, want 200 — the listing itself is fine", status, out) } if _, present := out["pipeline_entry"]; present { t.Fatalf("channels (unreadable pipeline) pipeline_entry = %v, want the key OMITTED, not present (even as null)", out) } if _, present := out["pipeline_channels"]; present { t.Fatalf("channels (unreadable pipeline) pipeline_channels = %v, want the key OMITTED, not present (even as null)", out) } } // 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) } // (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) } } // 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 // belongs to both. if err := fx.st.CreateAccount(2, "bac", "bab", "alice@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-sqid-name-collision") 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("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 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() sqidA, sqidB := domains.Sqid(2), domains.Sqid(3) if err := fx.st.CreateAccount(2, sqidA, "widgets", "alice@keymail.dev", now); err != nil { t.Fatal(err) } if err := fx.st.CreateAccount(3, sqidB, "widgets", "alice@keymail.dev", now); err != nil { t.Fatal(err) } token := cliToken(t, fx, "alice@keymail.dev", "code-ambiguous-name") status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/widgets/anyapp/channels", token) if status != http.StatusConflict { t.Fatalf("ambiguous-name channels = %d %v, want 409", status, out) } msg := str(t, out, "error") 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 // EqualFold, matching resolveAccount's own doctrine (accounts.go). func TestAPIAccountNameMatchIsCaseInsensitive(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() sqid := domains.Sqid(2) if err := fx.st.CreateAccount(2, sqid, "Widgets", "alice@keymail.dev", now); err != nil { t.Fatal(err) } if _, err := fx.st.CreateAppIn(2, "gizmo", "alice@keymail.dev", now); err != nil { t.Fatal(err) } token := cliToken(t, fx, "alice@keymail.dev", "code-case-insensitive-name") status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/widgets/gizmo/channels", token) if status != http.StatusOK { 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) } // Additive (carlos-deploy §3): the CLI learns from this same answer // whether what it just moved is waiting out a bake window. A canary never // bakes, so this one is not held. if v, present := out["held"]; !present || v != false { t.Fatalf("promote held = %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) } } // TestAPIPromotePipelinedChannelNamesItselfInTo: the promote door's "to" // field is PromoteResult.To, which was the target channel's RUNG — and an // app-defined channel is not a rung, so a pipelined promotion answered with // "to": "". The CLI does not print it (it prints what it asked for), but the // field is the API's own record of where the build went, and "" is a worse // answer than the channel's name for every other client. Wire-additive: a // fuller string where an empty one used to sit. func TestAPIPromotePipelinedChannelNamesItselfInTo(t *testing.T) { fx := newConsoleFixture(t) token := cliToken(t, fx, "paul@keymail.dev", "code-promote-pipelined") if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "piped"}, token); status != http.StatusOK { t.Fatalf("create app = %d %v", status, out) } if status, out := putJSON(t, fx.ts.URL+"/api/cli/apps/bab/piped/pipeline", map[string]any{"channels": []map[string]any{{"name": "edge"}, {"name": "production"}}}, token); status != http.StatusOK { t.Fatalf("pipeline put = %d %v", status, out) } shipVersion(t, fx, "piped", "abc1234") if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/piped/promote", map[string]string{"version": "abc1234", "channel": "edge"}, token); status != http.StatusOK { t.Fatalf("promote to edge = %d %v", status, out) } status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/piped/promote", map[string]string{"version": "abc1234", "channel": "production"}, token) if status != http.StatusOK { t.Fatalf("promote to production = %d %v", status, out) } if got := str(t, out, "to"); got != "production" { t.Errorf("promote body to = %q, want %q: %v", got, "production", out) } if got := str(t, out, "from"); got != "edge" { t.Errorf("promote body from = %q, want %q: %v", got, "edge", out) } } // 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) } // The refusal must be true, not just spoken: neither call may have moved // the rung gate to a spot where PromoteOnLadder ran first and the 403 was // merely decorative. If a future edit reordered the gate below // PromoteOnLadder, this is what would catch it — the test above would // still pass on status and body alone. if _, err := release.Channel(context.Background(), fx.rel, fx.pub, release.OpsAccount, "titogo", "stable"); err == nil { t.Fatalf("stable channel exists after two refused promotions, want it absent") } } // 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")) } } // TestAPIPromoteHotfixIsOrderOnly: `hotfix` became a real field on this door // with release pipelines v2 (§5, and the CLI's own client-side refusal of // -hotfix in API mode goes with it). It bypasses ORDER, and that is the whole // of what it does — the stable rule a pipeline-less app carries still refuses // it, byte for byte. // // It replaces TestAPIPromoteNoHotfix, which pinned the previous shape (the // request struct had no such field, so the key was silently ignored). The // change is deliberate: the flag is the sanctioned, recorded bypass of the // ladder's ORDER, and it was never the thing standing between a token and // stable — the rung gate is. func TestAPIPromoteHotfixIsOrderOnly(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-hotfix") promoteURL := fx.ts.URL + "/api/cli/apps/bab/titogo/promote" // abc1234 is on no rung yet (nothing has promoted it there), so promoting // straight to "beta" is the ladder-ORDERING refusal (beta is entered from // edge only) — not the "not a rung" arm a nested non-canary segment like // "beta/x" would hit instead. status, out := postJSON(t, promoteURL, map[string]any{"version": "abc1234", "channel": "beta"}, token) if status != http.StatusConflict { t.Fatalf("ladder-skip promote = %d %v, want 409", status, out) } if !strings.Contains(str(t, out, "error"), "beta is entered from edge only") { t.Fatalf("ladder-skip error = %q, want the ladder-ordering refusal text", str(t, out, "error")) } if _, err := release.Channel(context.Background(), fx.rel, fx.pub, release.OpsAccount, "titogo", "beta"); err == nil { t.Fatalf("beta channel exists after a refused promotion, want it absent") } // The same request with the flag lands, and the pointer records it. status, out = postJSON(t, promoteURL, map[string]any{"version": "abc1234", "channel": "beta", "hotfix": true}, token) if status != http.StatusOK || str(t, out, "result") != "promoted" { t.Fatalf("hotfixed ladder-skip = %d %v, want 200 promoted", status, out) } p := fx.channelVersion(t, "titogo", "beta") if p.Version != "abc1234" { t.Fatalf("beta serves %q, want abc1234", p.Version) } if len(p.Audit) == 0 || !p.Audit[0].Hotfix { t.Fatalf("beta's audit trail = %+v, want the bypass recorded on the pointer", p.Audit) } // And it buys nothing at stable, which is a RULE, not an order. status, out = postJSON(t, promoteURL, map[string]any{"version": "abc1234", "channel": "stable", "hotfix": true}, token) if status != http.StatusForbidden { t.Fatalf("hotfixed stable promote = %d %v, want the 403 passkey refusal", status, out) } if _, err := release.Channel(context.Background(), fx.rel, fx.pub, release.OpsAccount, "titogo", "stable"); err == nil { t.Fatalf("stable 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) } } // TestAPIRollbackStableRefused: Paul's ruling (2026-08-03) — a rollback that // would land on stable is refused with the SAME byte-identical 403 as a // stable promote, for a member token and an owner token alike, and the // pointer it would have touched is left exactly as it was. func TestAPIRollbackStableRefused(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") // A stable channel with a real history to roll back to — so a refusal // that let the rollback through would be visible in what "stable" points // at afterwards, not just in a channel that never existed. seedChannel(t, fx, "titogo", "stable", "abc1234") seedChannel(t, fx, "titogo", "stable", "def5678") if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { t.Fatal(err) } owner := cliToken(t, fx, "paul@keymail.dev", "code-rollback-stable-owner") member := cliToken(t, fx, "alice@keymail.dev", "code-rollback-stable-member") body := map[string]string{"channel": "stable"} statusO, outO := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/rollback", body, owner) statusM, outM := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/rollback", body, member) if statusO != http.StatusForbidden || statusM != http.StatusForbidden { t.Fatalf("stable rollback = owner %d, member %d, want both 403", statusO, statusM) } if outO != outM { t.Fatalf("stable rollback 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 rollback refusal = %q, want console_url %q", outO, wantURL) } if !strings.Contains(outO, "passkey") { t.Fatalf("stable rollback refusal = %q, want it to name the passkey requirement", outO) } if cp := fx.channelVersion(t, "titogo", "stable"); cp.Version != "def5678" { t.Fatalf("stable channel after two refused rollbacks = %q, want def5678 (unchanged)", cp.Version) } } // ---- ship (apicli.go) ------------------------------------------------------- // // `carlos ship`'s door: a multipart upload whose bytes are hashed and sized // HERE, server-side, from what actually arrived on the wire — never from a // field a caller could set, because there is no such field on this wire // shape (the meta part carries only version and kind). // shipPart is one part of a multipart ship request, in the order it should // be written — order matters, since the handler insists "meta" come first. type shipPart struct { name string body []byte } // metaPart builds the JSON "meta" part every ship request starts with. func metaPart(t *testing.T, version, kind string) shipPart { t.Helper() raw, err := json.Marshal(map[string]string{"version": version, "kind": kind}) if err != nil { t.Fatal(err) } return shipPart{name: "meta", body: raw} } // metaPartFull is metaPart with label and notes attached, and a version that // may be empty — the shape a target-minting ship (release versioning spec) // puts on the wire, alongside the label a human types at ship time. func metaPartFull(t *testing.T, version, kind, label, notes string) shipPart { t.Helper() raw, err := json.Marshal(map[string]string{ "version": version, "kind": kind, "label": label, "notes": notes, }) if err != nil { t.Fatal(err) } return shipPart{name: "meta", body: raw} } // randomBytes is a stand-in artifact payload: real bytes, not a fixed // string, so a hash mismatch would actually be caught rather than a lucky // coincidence. func randomBytes(t *testing.T, n int) []byte { t.Helper() b := make([]byte, n) if _, err := rand.Read(b); err != nil { t.Fatal(err) } return b } // liftShipBudget takes the ship rate limiter out of a test's way. // // cliShipBurst is 2 a minute (authcli.go) — deliberately small, because the // caller it bounds is a release upload. A test that ships three or more // times is almost always testing something else entirely (immutability, // minting, the capability vocabulary, the concurrency semaphore) and would // otherwise be refused 429 partway through by a gate it is not about. Worse // for the semaphore test: the rate limiter sits IN FRONT of the semaphore, // so without this the 429 it asserts on would be the wrong 429 and the test // would prove nothing. // // The budget itself is pinned elsewhere and stays pinned: // TestPersonShipBudgetStaysKeyedByToken and // TestServiceShipBudgetIsOwnAndKeyedByName (authcli_test.go) run against the // real constants and must never call this. func liftShipBudget(fx *consoleFixture) { fx.srv.cli.ship = newRateLimiter(1<<20, time.Minute) fx.srv.cli.svcShip = newRateLimiter(1<<20, time.Minute) } // postShip POSTs a multipart/form-data ship request built from parts, in // the order given, and decodes whatever JSON comes back (a refusal that // never reaches the handler's own JSON responses — e.g. a body cut short by // MaxBytesReader — decodes to an empty map, which the caller only inspects // via the status code). func postShip(t *testing.T, u, bearer string, parts []shipPart) (int, map[string]any) { t.Helper() status, _, out := postShipRaw(t, u, bearer, parts) return status, out } // postShipRaw is postShip with the response HEADERS kept — the refusal that // needs them is the 429, whose Retry-After is the whole point of the answer // for a non-human caller and is invisible in the JSON body. func postShipRaw(t *testing.T, u, bearer string, parts []shipPart) (int, http.Header, map[string]any) { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) for _, p := range parts { fw, err := mw.CreateFormFile(p.name, p.name) if err != nil { t.Fatal(err) } if _, err := fw.Write(p.body); err != nil { t.Fatal(err) } } if err := mw.Close(); err != nil { t.Fatal(err) } req, err := http.NewRequest(http.MethodPost, u, &buf) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", mw.FormDataContentType()) if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() raw, err := io.ReadAll(res.Body) if err != nil { t.Fatal(err) } out := map[string]any{} _ = json.Unmarshal(raw, &out) return res.StatusCode, res.Header, out } // TestAPIShipHappy: a fresh binary release ships through the multipart // door, lands at the exact bucket key the direct-to-bucket path would use, // and the manifest the server writes carries hashes/sizes it computed // itself — matching what the artifact bytes actually hash to, and BuiltBy // naming the console API as the shipper. func TestAPIShipHappy(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-ship-happy") payload := randomBytes(t, 1024) status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}}) if status != http.StatusCreated { t.Fatalf("ship = %d %v", status, out) } if str(t, out, "result") != "shipped" || str(t, out, "version") != "abc1234" { t.Fatalf("ship body = %v", out) } sum := sha256.Sum256(payload) wantSHA := hex.EncodeToString(sum[:]) arts, _ := out["artifacts"].(map[string]any) arm64, _ := arts["linux-arm64"].(map[string]any) if arm64 == nil { t.Fatalf("ship artifacts = %v, want linux-arm64", arts) } if str(t, arm64, "sha256") != wantSHA { t.Fatalf("artifact sha256 = %v, want %s (computed from the bytes sent)", arm64["sha256"], wantSHA) } if sz, _ := arm64["size"].(float64); int64(sz) != int64(len(payload)) { t.Fatalf("artifact size = %v, want %d", arm64["size"], len(payload)) } ctx := context.Background() stored, _, err := fx.rel.Get(ctx, release.ArtifactKey(release.OpsAccount, "titogo", "abc1234", "linux-arm64")) if err != nil { t.Fatalf("stored artifact bytes: %v", err) } if !bytes.Equal(stored, payload) { t.Fatalf("stored artifact bytes differ from what was shipped") } m, err := release.GetManifest(ctx, fx.rel, release.OpsAccount, "titogo", "abc1234") if err != nil { t.Fatalf("manifest: %v", err) } a, ok := m.Artifacts["linux-arm64"] if !ok { t.Fatalf("manifest artifacts = %v, want linux-arm64", m.Artifacts) } if a.SHA256 != wantSHA || a.Size != int64(len(payload)) { t.Fatalf("manifest artifact = %+v, want sha256=%s size=%d", a, wantSHA, len(payload)) } if !strings.HasPrefix(m.BuiltBy, "console-api ") { t.Fatalf("manifest built_by = %q, want prefix %q", m.BuiltBy, "console-api ") } events, err := fx.st.Events(OpsAccountID, "titogo", 10) if err != nil { t.Fatal(err) } found := false for _, e := range events { if e.Action == "ship" && strings.Contains(e.Detail, "(cli)") { found = true } } if !found { t.Fatalf("events = %v, want a ship row whose detail names the CLI", events) } } // TestAPIShipStatic: a static site release ships under the single // "site.tar.gz" artifact name the static kind requires. func TestAPIShipStatic(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "website", "paul@keymail.dev", now); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-ship-static") payload := randomBytes(t, 512) status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/website/releases", token, []shipPart{metaPart(t, "def5678", "static"), {name: "site.tar.gz", body: payload}}) if status != http.StatusCreated { t.Fatalf("ship static = %d %v", status, out) } arts, _ := out["artifacts"].(map[string]any) if _, ok := arts["site.tar.gz"]; !ok { t.Fatalf("ship static artifacts = %v, want site.tar.gz", arts) } } // TestAPIShipRefusals: every malformed shape of a ship request is a 400, // and none of them reach release.Ship. func TestAPIShipRefusals(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-ship-refusals") liftShipBudget(fx) payload := randomBytes(t, 128) cases := []struct { name string parts []shipPart }{ {"bad version chars", []shipPart{ metaPart(t, "../etc/passwd", "binary"), {name: "linux-arm64", body: payload}}}, {"bad kind", []shipPart{ metaPart(t, "abc1234", "weird"), {name: "linux-arm64", body: payload}}}, {"binary part not a target name", []shipPart{ metaPart(t, "abc1234", "binary"), {name: "not-a-target", body: payload}}}, {"static part not site.tar.gz", []shipPart{ metaPart(t, "abc1234", "static"), {name: "linux-arm64", body: payload}}}, {"no file parts", []shipPart{ metaPart(t, "abc1234", "binary")}}, {"meta not first part", []shipPart{ {name: "linux-arm64", body: payload}, metaPart(t, "abc1234", "binary")}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, c.parts) if status != http.StatusBadRequest { t.Fatalf("%s = %d %v, want 400", c.name, status, out) } }) } } // TestAPIShipTooBig: apiShipMaxBytes bounds the WHOLE request, not any one // part — lowered here to 4KiB, an 8KiB artifact part is refused with 413 // before it is ever handed to release.Ship. func TestAPIShipTooBig(t *testing.T) { old := apiShipMaxBytes apiShipMaxBytes = 4 << 10 defer func() { apiShipMaxBytes = old }() 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-ship-toobig") payload := randomBytes(t, 8<<10) status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}}) if status != http.StatusRequestEntityTooLarge { t.Fatalf("oversized ship = %d %v, want 413", status, out) } } // TestAPIShipMemberGate: a non-member token gets the identical 404 every // other CLI-API door gives an unknown or someone-else's account — apiAccountApp // runs before any multipart parsing, so a garbage body never matters here. func TestAPIShipMemberGate(t *testing.T) { fx := newConsoleFixture(t) token := cliToken(t, fx, "eve@keymail.dev", "code-ship-gate") status, body := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", map[string]string{}, token) if status != http.StatusNotFound { t.Fatalf("ship as non-member = %d, want 404", status) } if body != "404 page not found\n" { t.Fatalf("ship as non-member body = %q, want the stdlib http.NotFound output", body) } } // TestAPIShipTooManyParts: apiShipMaxBytes bounds bytes, not COUNT — a body // of nothing but tiny, validly-named parts would otherwise fit millions of // them in one small request. The ninth binary part (max is // apiShipMaxPartsBinary=8) is refused before it is ever read, and nothing // from this request reaches release.Ship: no manifest for this version // exists afterward. func TestAPIShipTooManyParts(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-ship-toomanyparts") parts := []shipPart{metaPart(t, "abc1234", "binary")} for i := 0; i < 9; i++ { parts = append(parts, shipPart{name: fmt.Sprintf("part-%d", i), body: []byte("x")}) } status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, parts) if status != http.StatusBadRequest { t.Fatalf("9-part ship = %d %v, want 400", status, out) } if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "abc1234"); err == nil { t.Fatalf("manifest exists after a refused too-many-parts ship, want none — nothing should reach release.Ship") } } // TestAPIShipDuplicatePart: two parts claiming the same artifact name are // refused rather than letting the second silently win — both would // otherwise be charged against the byte and part-count budgets while only // the last one's bytes ever reached the manifest. func TestAPIShipDuplicatePart(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-ship-duplicate") status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, []shipPart{ metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: randomBytes(t, 32)}, {name: "linux-arm64", body: randomBytes(t, 32)}, }) if status != http.StatusBadRequest { t.Fatalf("duplicate-named ship = %d %v, want 400", status, out) } if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "abc1234"); err == nil { t.Fatalf("manifest exists after a refused duplicate-part ship, want none") } } // TestAPIShipTraversalRefusals: every part name that reads like an attempt // to escape the artifact's bucket-key segment is refused with 400, and — the // point of this test, not just its status code — none of them leave any // trace in the release store. Part names become bucket key segments via the // manifest (release.ArtifactKey joins them straight into the object path), // so this is path-traversal defense, not tidiness. func TestAPIShipTraversalRefusals(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-ship-traversal") liftShipBudget(fx) payload := randomBytes(t, 64) cases := []struct { name string version string kind string part string }{ {"dotdot slash etc passwd", "trv00001", "binary", "../../etc/passwd"}, {"nested traversal under a valid-looking prefix", "trv00002", "binary", "linux-arm64/../../x"}, {"bare dotdot", "trv00003", "binary", ".."}, {"empty part name", "trv00004", "binary", ""}, {"static traversal", "trv00005", "static", "../site.tar.gz"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, []shipPart{metaPart(t, c.version, c.kind), {name: c.part, body: payload}}) if status != http.StatusBadRequest { t.Fatalf("%s = %d %v, want 400", c.name, status, out) } if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", c.version); err == nil { t.Fatalf("%s: manifest exists for version %s, want none — release.Ship must never have been reached", c.name, c.version) } }) } } // TestAPIShipImmutableConflict: re-shipping an already-shipped version with // DIFFERENT bytes is a permanent 409, not the generic 502 every other // release.Ship failure gets — a caller retrying a 502 would re-upload // forever and never learn this can't succeed. Re-shipping the exact same // bytes, by contrast, must stay a success: it is the one case release.Ship // itself calls idempotent, and a caller retrying a dropped connection needs // that retry to actually land. func TestAPIShipImmutableConflict(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-ship-immutable") liftShipBudget(fx) payload := randomBytes(t, 256) status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}}) if status != http.StatusCreated { t.Fatalf("first ship = %d %v", status, out) } different := randomBytes(t, 256) status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: different}}) if status != http.StatusConflict { t.Fatalf("re-ship with different bytes = %d %v, want 409", status, out) } if !strings.Contains(str(t, out, "error"), "immutable") { t.Fatalf("conflict error = %q, want it to name immutability", str(t, out, "error")) } status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}}) if status != http.StatusOK { t.Fatalf("re-ship with identical bytes = %d %v, want 200 (idempotent, nothing created)", status, out) } if str(t, out, "result") != "shipped" || str(t, out, "version") != "abc1234" { t.Fatalf("idempotent re-ship body = %v", out) } } // tinyProgramForConsole is internal/release's tinyProgram, copied because a // test helper cannot be imported across packages: a stripped linux/arm64 Go // binary whose main package is modPath, built as the console is built, at // test time. Nothing is checked in. func tinyProgramForConsole(t *testing.T, modPath string) []byte { t.Helper() if testing.Short() { t.Skip("builds a binary") } dir := t.TempDir() must := func(err error) { t.Helper() if err != nil { t.Fatal(err) } } must(os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module "+modPath+"\n\ngo 1.22\n"), 0o644)) must(os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\nfunc main() {}\n"), 0o644)) out := filepath.Join(dir, "bin") cmd := exec.Command("go", "build", "-trimpath", "-ldflags=-s -w", "-o", out, ".") cmd.Dir = dir cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=arm64", "CGO_ENABLED=0", "GOFLAGS=-buildvcs=false") if b, err := cmd.CombinedOutput(); err != nil { t.Fatalf("build %s: %v\n%s", modPath, err, b) } b, err := os.ReadFile(out) must(err) return b } // TestAPIShipRefusesAProgramChange: the wrong-program rule at the API door. // A second release whose Go main package differs from the app's previous // release is a 409 naming carlos ship --program-change, and the same ship with the // meta part's program_change set goes through and is recorded. func TestAPIShipRefusesAProgramChange(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-ship-program") liftShipBudget(fx) url := fx.ts.URL + "/api/cli/apps/bab/titogo/releases" console := tinyProgramForConsole(t, "example.com/console") carlos := tinyProgramForConsole(t, "example.com/carlos") status, out := postShip(t, url, token, []shipPart{metaPart(t, "v1", "binary"), {name: "linux-arm64", body: console}}) if status != http.StatusCreated { t.Fatalf("first ship = %d %v", status, out) } status, out = postShip(t, url, token, []shipPart{metaPart(t, "v2", "binary"), {name: "linux-arm64", body: carlos}}) if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), "carlos ship --program-change") { t.Fatalf("second ship = %d %v, want 409 naming carlos ship --program-change", status, out) } raw, err := json.Marshal(map[string]any{"version": "v3", "kind": "binary", "program_change": true}) if err != nil { t.Fatal(err) } status, out = postShip(t, url, token, []shipPart{{name: "meta", body: raw}, {name: "linux-arm64", body: carlos}}) if status != http.StatusCreated { t.Fatalf("ship with program_change = %d %v, want 201", status, out) } m, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "v3") if err != nil { t.Fatal(err) } if m.Program != "example.com/carlos" || !m.ProgramChange { t.Fatalf("manifest program = %q change = %v, want example.com/carlos recorded as a change", m.Program, m.ProgramChange) } } // TestAPIShipReshipAfterAProgramChangeStaysIdempotent: the wrong-program rule // judges only a NEW version. Re-shipping v1 with identical bytes after the app // moved on to another program (v2, shipped with program_change) is still the // idempotent 200, not a 409 against v2's program. func TestAPIShipReshipAfterAProgramChangeStaysIdempotent(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-ship-program-reship") liftShipBudget(fx) url := fx.ts.URL + "/api/cli/apps/bab/titogo/releases" console := tinyProgramForConsole(t, "example.com/console") carlos := tinyProgramForConsole(t, "example.com/carlos") status, out := postShip(t, url, token, []shipPart{metaPart(t, "v1", "binary"), {name: "linux-arm64", body: console}}) if status != http.StatusCreated { t.Fatalf("v1 = %d %v", status, out) } raw, err := json.Marshal(map[string]any{"version": "v2", "kind": "binary", "program_change": true}) if err != nil { t.Fatal(err) } status, out = postShip(t, url, token, []shipPart{{name: "meta", body: raw}, {name: "linux-arm64", body: carlos}}) if status != http.StatusCreated { t.Fatalf("v2 with program_change = %d %v", status, out) } status, out = postShip(t, url, token, []shipPart{metaPart(t, "v1", "binary"), {name: "linux-arm64", body: console}}) if status != http.StatusOK { t.Fatalf("identical re-ship of v1 = %d %v, want 200 (idempotent)", status, out) } } // TestAPIShipConcurrencyLimit: the rate limiter bounds requests over TIME, // but says nothing about how many run AT ONCE. Two ships already in flight // (each holding the server's ship semaphore while its body trickles in, // via an io.Pipe under the test's control) leave a third concurrent // request no room — it must be refused immediately with 429, not queued // behind either of the first two. func TestAPIShipConcurrencyLimit(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-ship-concurrency") liftShipBudget(fx) // A hook (same shape as cliNotified): signalled once a request has // actually acquired shipSem, so this test waits on a real fact instead // of racing the two background goroutines below with a sleep. fx.srv.shipStarted = make(chan struct{}, 2) var wg sync.WaitGroup statuses := make([]int, 2) var closers []func() error for i := 0; i < 2; i++ { pr, pw := io.Pipe() closers = append(closers, pw.Close) req, err := http.NewRequest(http.MethodPost, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", pr) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "multipart/form-data; boundary=xshipboundaryx") req.Header.Set("Authorization", "Bearer "+token) wg.Add(1) idx := i go func() { defer wg.Done() res, err := http.DefaultClient.Do(req) if err != nil { statuses[idx] = -1 return } defer res.Body.Close() io.Copy(io.Discard, res.Body) statuses[idx] = res.StatusCode }() } for i := 0; i < 2; i++ { select { case <-fx.srv.shipStarted: case <-time.After(5 * time.Second): t.Fatal("timed out waiting for the first two ships to start (acquire shipSem)") } } status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}}) if status != http.StatusTooManyRequests { t.Fatalf("third concurrent ship = %d %v, want 429", status, out) } if !strings.Contains(str(t, out, "error"), "in progress") { t.Fatalf("third concurrent ship error = %q, want it to name the concurrency limit", str(t, out, "error")) } // Gate ORDER, pinned while the semaphore is still exhausted: a request // whose capability block is garbage must still be refused 429, not 400. // The capability decode was added inside the handler, AFTER the member // gate, the rate limiter and this semaphore — and it has to stay there. A // validation step that ran before them would let an unauthenticated or // over-budget caller learn things about this server's capability // vocabulary by feeding it blocks, and would do the work of decoding // them, which is precisely what the resource gates exist to refuse. status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, []shipPart{metaPartCaps(t, "abc1234", "binary", `{"v":1,"daemon":true,"gpu":true}`), {name: "linux-arm64", body: randomBytes(t, 64)}}) if status != http.StatusTooManyRequests { t.Fatalf("bad-capabilities ship under an exhausted semaphore = %d %v, want 429 — the caps decode must sit AFTER the resource gates", status, out) } // Unblock the two held-open requests (each now sees an immediately-EOF // body and is refused as an empty upload) so the test doesn't leak // goroutines, and confirm they actually finish. for _, c := range closers { c() } done := make(chan struct{}) go func() { wg.Wait(); close(done) }() select { case <-done: case <-time.After(5 * time.Second): t.Fatal("timed out waiting for the held-open ships to finish after closing their bodies") } for i, st := range statuses { if st != http.StatusBadRequest { t.Fatalf("held-open ship %d finished with status %d, want 400 (empty upload, once unblocked)", i, st) } } } // metaPartCaps is metaPart with an app's capability declaration attached — // the shape `carlos ship -capabilities ` puts on the wire. capsJSON is // spliced in raw so a test can send a block the strict decoder must refuse, // which a typed caps.Set could not even express. func metaPartCaps(t *testing.T, version, kind, capsJSON string) shipPart { t.Helper() raw, err := json.Marshal(map[string]any{ "version": version, "kind": kind, "capabilities": json.RawMessage(capsJSON), }) if err != nil { t.Fatal(err) } return shipPart{name: "meta", body: raw} } // TestAPIShipCarriesCapabilitiesIntoTheManifest: the declaration rides the // meta part and lands in the immutable manifest, decoded through caps's own // strict door. This is the console half of the ship-time declaration surface; // the direct CLI path's half is readCapabilities (cli.go). func TestAPIShipCarriesCapabilitiesIntoTheManifest(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "keymail", "paul@keymail.dev", now); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-ship-caps") payload := randomBytes(t, 128) const block = `{"v":1,"command":"relay","daemon":true,"children":true,` + `"runtime_dir":"keymail","ports":[{"port":25,"name":"mx"}],` + `"data":{"replicate":"app"},"host_role":true}` status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/keymail/releases", token, []shipPart{metaPartCaps(t, "cap01234", "binary", block), {name: "linux-arm64", body: payload}}) if status != http.StatusCreated { t.Fatalf("ship with capabilities = %d %v", status, out) } m, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "keymail", "cap01234") if err != nil { t.Fatalf("manifest: %v", err) } if m.Capabilities == nil { t.Fatal("the manifest carries no capability block") } c := m.Capabilities if c.V != 1 || c.Command != "relay" || !c.Daemon || !c.Children || c.RuntimeDir != "keymail" || !c.HostRole { t.Fatalf("capabilities = %+v, want the declared block", c) } if len(c.Ports) != 1 || c.Ports[0].Port != 25 || c.Ports[0].Name != "mx" { t.Fatalf("capabilities ports = %+v, want [{25 mx}]", c.Ports) } if c.Data == nil || c.Data.Replicate != caps.DataReplicateApp { t.Fatalf("capabilities data = %+v, want replicate=app", c.Data) } } // TestAPIShipRefusesABadCapabilityBlock: everything ValidCapabilities and the // strict decoder refuse is refused HERE, at ship, with the caps message // itself — a 400 that names the offending capability, not a generic // "malformed meta part". And release.Ship is never reached, so no manifest // exists afterwards for the box to trip over. func TestAPIShipRefusesABadCapabilityBlock(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) } payload := randomBytes(t, 128) cases := []struct { name string version string block string names string // a substring the refusal must contain }{ {"unknown capability name", "cap10001", `{"v":1,"daemon":true,"gpu":true}`, "gpu"}, {"unsupported block version", "cap10002", `{"v":2,"daemon":true}`, "update the platform binary"}, {"missing v", "cap10003", `{"daemon":true}`, `"v"`}, {"non-privileged port", "cap10004", `{"v":1,"ports":[{"port":8080}]}`, "8080"}, {"reserved port", "cap10005", `{"v":1,"ports":[{"port":443}]}`, "443"}, {"reserved runtime_dir", "cap10006", `{"v":1,"runtime_dir":"carlos"}`, "carlos"}, {"runtime_dir traversal", "cap10007", `{"v":1,"runtime_dir":"../etc"}`, "runtime_dir"}, {"dbs with replicate app", "cap10008", `{"v":1,"data":{"replicate":"app","dbs":["x.db"]}}`, "dbs"}, {"db name with a separator", "cap10009", `{"v":1,"data":{"replicate":"platform","dbs":["a/b.db"]}}`, "a/b.db"}, {"explicit null", "cap10010", `null`, "null"}, } // One token, with the ship budget lifted: this table is about the // capability vocabulary, and every case has to reach the decoder to pin // anything. It used to be capped at cliShipBurst cases instead, which // silently made the table's length a function of a rate limit that has // since moved. token := cliToken(t, fx, "paul@keymail.dev", "code-ship-badcaps") liftShipBudget(fx) for _, c := range cases { t.Run(c.name, func(t *testing.T) { status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, []shipPart{metaPartCaps(t, c.version, "binary", c.block), {name: "linux-arm64", body: payload}}) if status != http.StatusBadRequest { t.Fatalf("%s = %d %v, want 400", c.name, status, out) } msg := str(t, out, "error") if !strings.Contains(msg, c.names) { t.Fatalf("%s: refusal %q does not contain %q", c.name, msg, c.names) } // No refusal on this path may leak an internal Go type name — a // caller cannot act on one, and it is not this API's vocabulary. if strings.Contains(msg, "caps.Set") || strings.Contains(msg, "json:") { t.Fatalf("%s: refusal %q leaks the Go decoder's own message", c.name, msg) } if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", c.version); err == nil { t.Fatalf("%s: a manifest exists for %s — release.Ship must never have been reached", c.name, c.version) } }) } } // TestAPIShipRefusesANonObjectCapabilityBlock: a "capabilities" value that is // not an object at all never reaches caps's own vocabulary — encoding/json // refuses it first, and ITS message names an internal Go type ("json: cannot // unmarshal number into Go value of type caps.Set"), carries no prefix, and // tells the caller nothing they can act on. It is replaced with one fixed // line in the same register as this handler's other 400s ("malformed meta // part"). Pinned so the raw decoder error can never leak back into the API's // vocabulary. func TestAPIShipRefusesANonObjectCapabilityBlock(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-ship-nonobject") liftShipBudget(fx) payload := randomBytes(t, 128) for _, c := range []struct{ name, version, block string }{ {"a number", "cap20001", `7`}, {"a string", "cap20002", `"daemon"`}, {"an array", "cap20003", `[{"v":1}]`}, } { t.Run(c.name, func(t *testing.T) { status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, []shipPart{metaPartCaps(t, c.version, "binary", c.block), {name: "linux-arm64", body: payload}}) if status != http.StatusBadRequest { t.Fatalf("%s = %d %v, want 400", c.name, status, out) } msg := str(t, out, "error") if !strings.Contains(msg, "malformed capabilities block") { t.Fatalf("%s: refusal = %q, want the fixed malformed-block line", c.name, msg) } if strings.Contains(msg, "caps.Set") || strings.Contains(msg, "json:") { t.Fatalf("%s: refusal %q leaks the Go decoder's own message", c.name, msg) } if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", c.version); err == nil { t.Fatalf("%s: a manifest exists for %s — release.Ship must never have been reached", c.name, c.version) } }) } } // TestAPIShipRefusesARecapabilitiedReship closes the hole the plan named: the // "identical content already shipped" short-circuit compares ARTIFACTS, so // without a capability comparison a second ship of the same version with a // DIFFERENT block would answer 200 and silently keep the FIRST block — the // immutability promise broken for exactly the field that decides which user // the app runs as and which privileged ports it binds. // // All four transitions are pinned, because three of them are the ones a // mistake actually takes: adding a block, changing one, and removing one. // Re-shipping the SAME block stays the idempotent 200 it has to be, or a // caller retrying a dropped connection could never land. func TestAPIShipRefusesARecapabilitiedReship(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "keymail", "paul@keymail.dev", now); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-ship-recaps") liftShipBudget(fx) payload := randomBytes(t, 256) u := fx.ts.URL + "/api/cli/apps/bab/keymail/releases" const first = `{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}]}` status, out := postShip(t, u, token, []shipPart{metaPartCaps(t, "recap001", "binary", first), {name: "linux-arm64", body: payload}}) if status != http.StatusCreated { t.Fatalf("first ship = %d %v", status, out) } // Same bytes, same version, a DIFFERENT block: 409, naming capabilities. const changed = `{"v":1,"daemon":true,"ports":[{"port":587,"name":"submission"}]}` status, out = postShip(t, u, token, []shipPart{metaPartCaps(t, "recap001", "binary", changed), {name: "linux-arm64", body: payload}}) if status != http.StatusConflict { t.Fatalf("re-ship with different capabilities = %d %v, want 409", status, out) } if msg := str(t, out, "error"); !strings.Contains(msg, "capabilities") || !strings.Contains(msg, "immutable") { t.Fatalf("conflict error = %q, want it to name capabilities and immutability", msg) } // Same bytes, no block at all: also a 409 — dropping a declaration is as // much a change of what the version means as adding one. status, out = postShip(t, u, token, []shipPart{metaPart(t, "recap001", "binary"), {name: "linux-arm64", body: payload}}) if status != http.StatusConflict { t.Fatalf("re-ship with the block removed = %d %v, want 409", status, out) } // And the stored block is still the FIRST one: no refused re-ship ever // rewrote it. m, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "keymail", "recap001") if err != nil { t.Fatalf("manifest: %v", err) } if m.Capabilities == nil || len(m.Capabilities.Ports) != 1 || m.Capabilities.Ports[0].Port != 25 { t.Fatalf("stored capabilities = %+v, want the first block (port 25)", m.Capabilities) } // The genuinely identical re-ship is still the idempotent 200. status, out = postShip(t, u, token, []shipPart{metaPartCaps(t, "recap001", "binary", first), {name: "linux-arm64", body: payload}}) if status != http.StatusOK { t.Fatalf("identical re-ship = %d %v, want 200", status, out) } // The mirror case: a version shipped with NO block cannot grow one. status, out = postShip(t, u, token, []shipPart{metaPart(t, "recap002", "binary"), {name: "linux-arm64", body: payload}}) if status != http.StatusCreated { t.Fatalf("plain ship = %d %v", status, out) } status, out = postShip(t, u, token, []shipPart{metaPartCaps(t, "recap002", "binary", first), {name: "linux-arm64", body: payload}}) if status != http.StatusConflict { t.Fatalf("re-ship that ADDS capabilities = %d %v, want 409", status, out) } } // TestShipCapabilitiesMatchComparesEncodingsNotStructs pins WHY the 409's // comparison is done on canonical JSON: caps.Set carries an unexported notes // slice, so a Set decoded from a block containing an unknown-but-tolerated // field is neither ==-comparable nor reflect.DeepEqual-equal to the same // capabilities expressed without it — even though the two mean identically // the same thing and marshal to identically the same manifest bytes. A // struct comparison here would 409 an honest, idempotent re-ship. func TestShipCapabilitiesMatchComparesEncodingsNotStructs(t *testing.T) { decode := func(s string) *caps.Set { t.Helper() var set *caps.Set if err := json.Unmarshal([]byte(s), &set); err != nil { t.Fatalf("decode %s: %v", s, err) } return set } withNote := decode(`{"v":1,"data":{"replicate":"platform","dbs":["console.db"],"future":7}}`) plain := decode(`{"v":1,"data":{"replicate":"platform","dbs":["console.db"]}}`) if len(withNote.Notes()) == 0 { t.Fatal("fixture no longer produces a note — this test is not testing what it claims") } if reflect.DeepEqual(withNote, plain) { t.Fatal("the two Sets are DeepEqual — the unexported notes hazard is gone and this test can be simplified") } if !shipCapabilitiesMatch(withNote, plain) { t.Fatal("shipCapabilitiesMatch called two blocks that reach the manifest as identical bytes different — an honest re-ship would 409") } // List ORDER is significant, and that is the documented, deliberate // behaviour: the question under immutability is "is this the same // declaration", and sorting here would let this function decide that two // manifests an operator can see differ are the same release. Pinned so a // future "helpful" sort is a red test rather than a silent loosening. if shipCapabilitiesMatch( decode(`{"v":1,"ports":[{"port":25},{"port":587}]}`), decode(`{"v":1,"ports":[{"port":587},{"port":25}]}`)) { t.Fatal("reordered ports matched — the comparison must stay order-sensitive") } if !shipCapabilitiesMatch(nil, nil) { t.Fatal("two capability-free manifests must match") } if shipCapabilitiesMatch(nil, plain) || shipCapabilitiesMatch(plain, nil) { t.Fatal("nil and a declared block must not match — adding or removing a block changes the version") } } // TestAPIShipEchoesTheStoredCapabilityBlock is the server half of the removal // signal (2026-08-03 review, I3). A console built before `capabilities` // existed ignores the unknown key — Go's decoder does that by default — stores // a manifest that declares NOTHING, and answers 201; on a box, a manifest that // resolves and declares nothing is the REMOVAL instruction, so the drop-ins go // and :25 comes off a running mail server. The echo is what lets a client tell // the two consoles apart before it promotes anything: this console says back // what it stored. // // Both success paths carry it — the 201 that wrote bytes and the 200 that // matched an identical version already on record — because a re-ship is // exactly as capable of being answered by an old console as a first ship. func TestAPIShipEchoesTheStoredCapabilityBlock(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "keymail", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-ship-echo") payload := randomBytes(t, 128) const block = `{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}],"host_role":true}` parts := func() []shipPart { return []shipPart{metaPartCaps(t, "echo0001", "binary", block), {name: "linux-arm64", body: payload}} } status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/keymail/releases", token, parts()) if status != http.StatusCreated { t.Fatalf("ship with capabilities = %d %v", status, out) } want := map[string]any{ "v": float64(1), "daemon": true, "host_role": true, "ports": []any{map[string]any{"port": float64(25), "name": "mx"}}, } got, _ := out["capabilities"].(map[string]any) if !reflect.DeepEqual(got, want) { t.Fatalf("201 ship echoed capabilities %v, want %v", out["capabilities"], want) } // The identical re-ship: 200, nothing written, and the block STILL echoed — // read back from the stored manifest this time. status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/keymail/releases", token, parts()) if status != http.StatusOK { t.Fatalf("identical re-ship = %d %v, want 200", status, out) } got, _ = out["capabilities"].(map[string]any) if !reflect.DeepEqual(got, want) { t.Fatalf("200 re-ship echoed capabilities %v, want %v", out["capabilities"], want) } } // TestAPIShipOmitsTheEchoWhenNothingIsDeclared: a version with no capability // block carries no "capabilities" key at all, so the response bytes for every // ship the fleet does today are byte-for-byte what they were. Absence is also // the honest answer — there is no stored block to echo — and it is what the // client keys its refusal off, so a key present-but-null here would defeat the // whole check. func TestAPIShipOmitsTheEchoWhenNothingIsDeclared(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-ship-noecho") status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, []shipPart{metaPart(t, "echo0002", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}}) if status != http.StatusCreated { t.Fatalf("ship = %d %v", status, out) } if _, ok := out["capabilities"]; ok { t.Fatalf("a capability-free ship echoed a capabilities key: %v", out) } } // TestAPIShipMintsFromTarget: an empty "version" in the meta part is a // refusal naming `carlos version target` when no target is set, and mints // successive iteration versions off the target once one is (release // versioning spec, task 4/1's GetTarget/NextIteration). The label and notes // sent alongside a minted ship land in the stored manifest. func TestAPIShipMintsFromTarget(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-ship-mint") liftShipBudget(fx) // 1. No target set: an empty version is refused, naming the fix. status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/km/releases", token, []shipPart{metaPart(t, "", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}}) if status != http.StatusBadRequest { t.Fatalf("empty version, no target = %d %v, want 400", status, out) } if !strings.Contains(str(t, out, "error"), "carlos version target") { t.Fatalf("refusal = %q, want it to name `carlos version target`", str(t, out, "error")) } // 2. Set a target. ctx := context.Background() if err := release.SetTarget(ctx, fx.rel, release.OpsAccount, "km", "0.5.0", "t", time.Now().UTC()); err != nil { t.Fatalf("set target: %v", err) } // 3. Ship with an empty version, a label and notes: mints 0.5.0-1. status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/km/releases", token, []shipPart{metaPartFull(t, "", "binary", "fixing modals", "- x"), {name: "linux-arm64", body: randomBytes(t, 64)}}) if status != http.StatusCreated { t.Fatalf("minted ship = %d %v, want 201", status, out) } if v := str(t, out, "version"); v != "0.5.0-1" { t.Fatalf("minted version = %q, want 0.5.0-1", v) } // 4. The stored manifest carries the label and notes. m, err := release.GetManifest(ctx, fx.rel, release.OpsAccount, "km", "0.5.0-1") if err != nil { t.Fatalf("manifest: %v", err) } if m.Label != "fixing modals" || m.Notes != "- x" { t.Fatalf("manifest label/notes = %q/%q, want %q/%q", m.Label, m.Notes, "fixing modals", "- x") } // 5. A second minted ship advances the iteration. status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/km/releases", token, []shipPart{metaPart(t, "", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}}) if status != http.StatusCreated { t.Fatalf("second minted ship = %d %v, want 201", status, out) } if v := str(t, out, "version"); v != "0.5.0-2" { t.Fatalf("second minted version = %q, want 0.5.0-2", v) } } // TestAPIShipLabelLimits: a label or notes outside release.MaxLabelLen / // MaxNotesLen, or a label spanning more than one line, is refused 400 before // any artifact bytes are stored — the same "refused before the wire cost" // property the capability block already has. func TestAPIShipLabelLimits(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-ship-labellimits") liftShipBudget(fx) cases := []struct { name string version string label string notes string wantErr string // substring the refusal must contain }{ {"label too long", "lbl0001", strings.Repeat("x", release.MaxLabelLen+1), "", "label: one line"}, {"label multi-line", "lbl0002", "line one\nline two", "", "label: one line"}, // One byte over MaxNotesLen — well inside apiShipMetaMaxBytes, so this // must reach release.ValidateLabelNotes's own refusal rather than // tripping the meta part's outer size limit first (the bug the // apiShipMetaMaxBytes budget exists to avoid: a truncated-JSON // "malformed meta part" instead of the notes-specific message). {"notes too big", "lbl0003", "", strings.Repeat("x", release.MaxNotesLen+1), "notes: at most"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, []shipPart{metaPartFull(t, c.version, "binary", c.label, c.notes), {name: "linux-arm64", body: randomBytes(t, 64)}}) if status != http.StatusBadRequest { t.Fatalf("%s = %d %v, want 400", c.name, status, out) } if !strings.Contains(str(t, out, "error"), c.wantErr) { t.Fatalf("%s: error = %q, want it to contain %q (proves the real check was reached, not a truncated meta part)", c.name, str(t, out, "error"), c.wantErr) } if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", c.version); err == nil { t.Fatalf("%s: a manifest was stored despite the refusal", c.name) } }) } } // TestAPIShipLargeNotesSucceed: notes comfortably within release.MaxNotesLen // but well past the meta part's OLD flat 8KiB reader limit must still ship — // apiShipMetaMaxBytes exists precisely so a caller inside the documented // ceiling never meets "malformed meta part" for a value the spec calls // valid. func TestAPIShipLargeNotesSucceed(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-ship-bignotes") notes := strings.Repeat("- fixed a thing\n", 640) // ~10KiB, well past the old 8KiB cap if len(notes) <= 8<<10 || len(notes) > release.MaxNotesLen { t.Fatalf("test fixture notes = %d bytes, want >8KiB and <= MaxNotesLen(%d)", len(notes), release.MaxNotesLen) } status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, []shipPart{metaPartFull(t, "big0001", "binary", "", notes), {name: "linux-arm64", body: randomBytes(t, 64)}}) if status != http.StatusCreated { t.Fatalf("~10KiB notes ship = %d %v, want 201", status, out) } m, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "big0001") if err != nil { t.Fatalf("manifest: %v", err) } if m.Notes != notes { t.Fatalf("stored notes length = %d, want %d (bytes must survive intact)", len(m.Notes), len(notes)) } } // TestAPIShipExplicitVersionStillWorks: a meta part naming an explicit // version and no label/notes ships exactly as it did before this change — // the mint-or-validate branch must not disturb the existing explicit-version // path. func TestAPIShipExplicitVersionStillWorks(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-ship-explicit") status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, []shipPart{metaPart(t, "de2766f8", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}}) if status != http.StatusCreated { t.Fatalf("explicit-version ship = %d %v, want 201", status, out) } if v := str(t, out, "version"); v != "de2766f8" { t.Fatalf("version = %q, want de2766f8", v) } m, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "de2766f8") if err != nil { t.Fatalf("manifest: %v", err) } if m.Label != "" || m.Notes != "" { t.Fatalf("manifest label/notes = %q/%q, want both empty", m.Label, m.Notes) } } // TestAPIShipReshipToleratesADifferentLabel: labels and notes are metadata, // not content — a re-ship of an already-shipped version's exact bytes still // succeeds when the label differs, the same tolerance BuiltAt already gets. // The STORED manifest keeps its ORIGINAL label: the short-circuit answers // success without writing anything (see the comment above // shipArtifactsMatch/shipCapabilitiesMatch in handleAPIShip), so a second // ship can never rewrite a version's label after the fact. func TestAPIShipReshipToleratesADifferentLabel(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-ship-reshiplabel") payload := randomBytes(t, 128) status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, []shipPart{metaPartFull(t, "abc9999", "binary", "label A", ""), {name: "linux-arm64", body: payload}}) if status != http.StatusCreated { t.Fatalf("first ship = %d %v, want 201", status, out) } status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, []shipPart{metaPartFull(t, "abc9999", "binary", "label B", ""), {name: "linux-arm64", body: payload}}) if status != http.StatusOK { t.Fatalf("re-ship with a different label = %d %v, want 200 (metadata, not content)", status, out) } m, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "abc9999") if err != nil { t.Fatalf("manifest: %v", err) } if m.Label != "label A" { t.Fatalf("stored label = %q, want the ORIGINAL %q (immutable — a re-ship never rewrites it)", m.Label, "label A") } } // ---- trash and restore ------------------------------------------------------ // trashFixture is a console with titogo claimed in the ops account and a // MEMBER (not owner) token for it: trashing is a member action on purpose — // the 30-day window is what makes the reversible verb cheap. func trashFixture(t *testing.T, code string) (*consoleFixture, string) { t.Helper() fx := newConsoleFixture(t) now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { t.Fatal(err) } if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { t.Fatal(err) } return fx, cliToken(t, fx, "alice@keymail.dev", code) } // tombstone reads the trash marker straight from the fixture's own bucket — // never through s.cfg.Releases, which some tests below deliberately break. func tombstone(t *testing.T, fx *consoleFixture, app string) *release.AppTombstone { t.Helper() tomb, err := release.ReadAppTombstone(context.Background(), fx.rel, release.OpsAccount, app) if err != nil { t.Fatalf("read trash marker for %s: %v", app, err) } return tomb } // TestAPITrashAndRestoreApp is the whole round trip: a member trashes an app, // the marker lands in the BUCKET (which is where trash state actually // lives), the row follows, every other CLI door stops answering for that // app — and restore puts it all back. func TestAPITrashAndRestoreApp(t *testing.T) { fx, token := trashFixture(t, "code-trash-roundtrip") status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "") if status != http.StatusOK { t.Fatalf("delete = %d %s, want 200", status, out) } var res struct { Result string `json:"result"` RestorableUntil string `json:"restorable_until"` } if err := json.Unmarshal(out, &res); err != nil { t.Fatalf("delete body %s: %v", out, err) } if res.Result != "trashed" { t.Fatalf("delete result = %q, want trashed", res.Result) } until, err := time.Parse(time.RFC3339, res.RestorableUntil) if err != nil { t.Fatalf("restorable_until %q: %v", res.RestorableUntil, err) } if want := time.Now().Add(release.TrashCooloff); until.Sub(want) > time.Minute || want.Sub(until) > time.Minute { t.Fatalf("restorable_until = %s, want ~%s (now + the cooloff)", until, want) } tomb := tombstone(t, fx, "titogo") if tomb == nil { t.Fatal("no trash marker in the bucket after a 200 delete") } if tomb.Actor != "alice@keymail.dev" { t.Fatalf("marker actor = %q, want the token's own identity", tomb.Actor) } if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || has { t.Fatalf("HasAppIn after trash = %v %v, want false", has, err) } if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "titogo"); err != nil || !has { t.Fatalf("HasTrashedAppIn after trash = %v %v, want true", has, err) } // One clock, and it is the MARKER's: the auto-purge pass measures the 30 // days from there, so a row stamped with anything else would give the // Trash page a "days remaining" nothing honours. trashed, err := fx.st.TrashedAppsIn(OpsAccountID) if err != nil || len(trashed) != 1 || trashed[0].App != "titogo" { t.Fatalf("TrashedAppsIn = %+v %v, want exactly titogo", trashed, err) } if trashed[0].DeletedAt.Unix() != tomb.DeletedAt.Unix() { t.Fatalf("row deleted_at = %s, marker deleted_at = %s — they must be the same clock", trashed[0].DeletedAt, tomb.DeletedAt) } // Every other door resolves through the same live-apps-only lookup, so a // trashed app is simply not there any more. if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", token); status != http.StatusNotFound { t.Fatalf("channels on a trashed app = %d %s, want 404", status, body) } if status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", token, `{"version":"v1","channel":"canary/eu"}`); status != http.StatusNotFound { t.Fatalf("promote to a trashed app = %d %s, want 404", status, body) } if status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, ""); status != http.StatusNotFound { t.Fatalf("ship to a trashed app = %d %s, want 404", status, body) } status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restore", token, "") if status != http.StatusOK { t.Fatalf("restore = %d %s, want 200", status, out) } var back struct { Result string `json:"result"` Note string `json:"note"` } if err := json.Unmarshal(out, &back); err != nil { t.Fatalf("restore body %s: %v", out, err) } if back.Result != "restored" { t.Fatalf("restore result = %q, want restored", back.Result) } // Restore resurrects NO routes (spec §3, the anti-ghost rule) — the // answer has to say so, because nothing else will. if !strings.Contains(back.Note, "carlos add") { t.Fatalf("restore note = %q, want it to name `carlos add`", back.Note) } if tomb := tombstone(t, fx, "titogo"); tomb != nil { t.Fatalf("trash marker survived a restore: %+v", tomb) } if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || !has { t.Fatalf("HasAppIn after restore = %v %v, want true", has, err) } if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "titogo"); err != nil || has { t.Fatalf("HasTrashedAppIn after restore = %v %v, want false", has, err) } if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", token); status != http.StatusOK { t.Fatalf("channels after restore = %d %s, want 200", status, body) } } // TestAPITrashTwiceKeepsTheClock: the second trash must not buy another 30 // days. TrashApp is create-only, so the answer has to come from the marker // that is actually there, not from this request's own clock. func TestAPITrashTwiceKeepsTheClock(t *testing.T) { fx, token := trashFixture(t, "code-trash-twice") status, first := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "") if status != http.StatusOK { t.Fatalf("first delete = %d %s", status, first) } // The row is trashed now, so the second call resolves through the trashed // door… which delete does NOT use: a re-delete of a trashed app is a 404, // exactly like every other verb. Restore it first, trash it again, and // assert the marker's original clock is what comes back — the marker is // deliberately not re-written by a second trash. if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restore", token, ""); status != http.StatusOK { t.Fatalf("restore = %d %s", status, out) } // Write a marker by hand with an OLD timestamp, then trash through the // API: the API must report the existing marker's window, not a fresh one. old := time.Now().Add(-10 * 24 * time.Hour) if err := release.TrashApp(context.Background(), fx.rel, release.OpsAccount, "titogo", "paul@keymail.dev", old); err != nil { t.Fatal(err) } status, second := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "") if status != http.StatusOK { t.Fatalf("second delete = %d %s, want 200 (idempotent)", status, second) } var res struct { RestorableUntil string `json:"restorable_until"` } if err := json.Unmarshal(second, &res); err != nil { t.Fatal(err) } until, err := time.Parse(time.RFC3339, res.RestorableUntil) if err != nil { t.Fatalf("restorable_until %q: %v", res.RestorableUntil, err) } if want := old.Add(release.TrashCooloff); until.Sub(want) > time.Minute || want.Sub(until) > time.Minute { t.Fatalf("restorable_until = %s, want the EXISTING marker's window ~%s", until, want) } } // blindMarkerStore writes the trash marker happily and then refuses to read // it back — the shape a half-broken bucket (or a permissions split) takes, // and the one case where this handler has written something it cannot // describe. type blindMarkerStore struct{ release.Store } func (b blindMarkerStore) Get(ctx context.Context, key string) ([]byte, string, error) { if strings.HasSuffix(key, "/deleted.json") { return nil, "", errors.New("AccessDenied: s3://carlos-prod-secret/deleted.json request id ABC123") } return b.Store.Get(ctx, key) } // TestAPITrashUnconfirmedWindowIsRefused: when the marker cannot be re-read, // the handler must not invent a deadline. Auto-purge destroys at the // MARKER's clock, and the row's own clock never self-heals (materialize // short-circuits on an already-trashed row, TrashAppRow is guarded by // deleted_at = 0) — so a guess here is a wrong "days remaining" forever. // 502, nothing written, and the retry produces the truth. func TestAPITrashUnconfirmedWindowIsRefused(t *testing.T) { fx, token := trashFixture(t, "code-trash-unconfirmed") // The app was already trashed headless — the marker exists with its own // (old) clock while this console's row is still live, so the API call // below takes TrashApp's idempotent path. old := time.Now().Add(-9 * 24 * time.Hour) if err := release.TrashApp(context.Background(), fx.rel, release.OpsAccount, "titogo", "paul@keymail.dev", old); err != nil { t.Fatal(err) } fx.srv.cfg.Releases = blindMarkerStore{fx.rel} status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "") if status != http.StatusBadGateway { t.Fatalf("delete with an unreadable marker = %d %s, want 502", status, body) } if !strings.Contains(string(body), "try again") { t.Fatalf("refusal = %s, want it to invite the retry that produces the true window", body) } if strings.Contains(string(body), "AccessDenied") || strings.Contains(string(body), "s3://") { t.Fatalf("refusal leaks the store's own error text: %s", body) } // The row must be untouched: a row stamped now would outlive the marker's // real deadline and could never be corrected. if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "titogo"); err != nil || has { t.Fatalf("HasTrashedAppIn after an unconfirmed trash = %v %v, want false", has, err) } if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || !has { t.Fatalf("HasAppIn after an unconfirmed trash = %v %v, want the row still live", has, err) } // The retry, against a bucket that answers: the SAME idempotent path, // now reporting the marker's original window and stamping the row with it. fx.srv.cfg.Releases = fx.rel status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "") if status != http.StatusOK { t.Fatalf("retry delete = %d %s, want 200", status, out) } var res struct { RestorableUntil string `json:"restorable_until"` } if err := json.Unmarshal(out, &res); err != nil { t.Fatal(err) } until, err := time.Parse(time.RFC3339, res.RestorableUntil) if err != nil { t.Fatalf("restorable_until %q: %v", res.RestorableUntil, err) } if want := old.Add(release.TrashCooloff); until.Sub(want) > time.Minute || want.Sub(until) > time.Minute { t.Fatalf("restorable_until = %s, want the ORIGINAL marker's window ~%s", until, want) } trashed, err := fx.st.TrashedAppsIn(OpsAccountID) if err != nil || len(trashed) != 1 { t.Fatalf("TrashedAppsIn = %+v %v, want exactly titogo", trashed, err) } if trashed[0].DeletedAt.Unix() != old.UTC().Unix() { t.Fatalf("row deleted_at = %s, want the marker's own clock %s", trashed[0].DeletedAt, old.UTC()) } } // TestAPITrashNonMemberIs404: a token holder who is not a member of the // account learns nothing at all — not that the account exists, not that the // app does, and not that either has a trash. Byte-identical to the 404 a // member gets for a name that does not exist. func TestAPITrashNonMemberIs404(t *testing.T) { fx, member := trashFixture(t, "code-trash-member") stranger := cliToken(t, fx, "bob@keymail.dev", "code-trash-stranger") mStatus, mBody := postBody(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/delete", member, "") sStatus, sBody := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", stranger, "") if mStatus != http.StatusNotFound || sStatus != http.StatusNotFound { t.Fatalf("delete: member/unknown-app = %d, stranger/real-app = %d, want 404 both", mStatus, sStatus) } if string(mBody) != string(sBody) { t.Fatalf("delete 404s differ:\n member: %q\n stranger: %q", mBody, sBody) } // The restore door answers the same way, through its own resolver. mStatus, mBody = postBody(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/restore", member, "") sStatus, sBody = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restore", stranger, "") if mStatus != http.StatusNotFound || sStatus != http.StatusNotFound { t.Fatalf("restore: member/unknown-app = %d, stranger/real-app = %d, want 404 both", mStatus, sStatus) } if string(mBody) != string(sBody) { t.Fatalf("restore 404s differ:\n member: %q\n stranger: %q", mBody, sBody) } if tomb := tombstone(t, fx, "titogo"); tomb != nil { t.Fatalf("a stranger's delete wrote a marker: %+v", tomb) } if has, _ := fx.st.HasAppIn(OpsAccountID, "titogo"); !has { t.Fatal("a stranger's delete trashed the row") } } // TestAPIRestoreLiveAppIs404: restore answers only for apps that are // actually in the trash — a live app is not restorable, and saying so with // anything other than the standard 404 would make this door an oracle for // trash state. func TestAPIRestoreLiveAppIs404(t *testing.T) { fx, token := trashFixture(t, "code-restore-live") status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restore", token, "") if status != http.StatusNotFound { t.Fatalf("restore of a live app = %d %s, want 404", status, body) } if _, unknown := postBody(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/restore", token, ""); string(unknown) != string(body) { t.Fatalf("restore 404s differ:\n live: %q\n unknown: %q", body, unknown) } } // TestAPITrashProductionRefused: the production flag IS the deletion // protection (spec §5) — no checkbox, no break-glass. The refusal has to // name the off-switch, because "no" without a way forward is a support // ticket. func TestAPITrashProductionRefused(t *testing.T) { fx, token := trashFixture(t, "code-trash-production") if err := release.SetProduction(context.Background(), fx.rel, fx.priv, release.OpsAccount, "titogo", true, "paul@keymail.dev"); err != nil { t.Fatal(err) } status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "") if status != http.StatusConflict { t.Fatalf("delete of a production app = %d %s, want 409", status, body) } if !strings.Contains(string(body), "production") { t.Fatalf("refusal = %s, want it to name the production off-switch", body) } if tomb := tombstone(t, fx, "titogo"); tomb != nil { t.Fatalf("a refused delete wrote a marker: %+v", tomb) } if has, _ := fx.st.HasAppIn(OpsAccountID, "titogo"); !has { t.Fatal("a refused delete trashed the row") } } // unreadableProductionStore breaks exactly one read — the production flag's // object — which is the state anyone with bucket write but no signing key // can create at will. type unreadableProductionStore struct{ release.Store } func (u unreadableProductionStore) Get(ctx context.Context, key string) ([]byte, string, error) { if strings.HasSuffix(key, "/production.json") { return nil, "", errors.New("AccessDenied: s3://carlos-prod-secret/production.json request id ABC123") } return u.Store.Get(ctx, key) } // TestAPITrashProductionUnreadableRefused: when the bucket cannot say "not // production", the platform acts as if it had said "production" (spec §5) — // and NOTHING is trashed on the way out. func TestAPITrashProductionUnreadableRefused(t *testing.T) { fx, token := trashFixture(t, "code-trash-unreadable") fx.srv.cfg.Releases = unreadableProductionStore{fx.rel} status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "") if status != http.StatusConflict { t.Fatalf("delete with an unreadable flag = %d %s, want 409", status, body) } if !strings.Contains(string(body), "production") { t.Fatalf("refusal = %s, want it to name the production flag", body) } if strings.Contains(string(body), "AccessDenied") || strings.Contains(string(body), "s3://") { t.Fatalf("refusal leaks the store's own error text: %s", body) } if tomb := tombstone(t, fx, "titogo"); tomb != nil { t.Fatalf("a refused delete wrote a marker: %+v", tomb) } if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || !has { t.Fatalf("HasAppIn after a refused delete = %v %v, want the row still live", has, err) } if has, _ := fx.st.HasTrashedAppIn(OpsAccountID, "titogo"); has { t.Fatal("a refused delete trashed the row") } } // TestAPITrashWithoutAReleaseStore: a console with no bucket cannot trash at // all. A row-only trash would un-hide itself at the next materialize pass — // the bucket is where trash state lives. func TestAPITrashWithoutAReleaseStore(t *testing.T) { fx, token := trashFixture(t, "code-trash-nobucket") fx.srv.cfg.Releases = nil status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "") if status != http.StatusServiceUnavailable { t.Fatalf("delete with no release store = %d %s, want 503", status, body) } if has, _ := fx.st.HasAppIn(OpsAccountID, "titogo"); !has { t.Fatal("a 503 delete trashed the row anyway") } } // TestAPITrashNoToken: both doors answer an anonymous caller with cliUser's // one 401 — before any name in the path is resolved. func TestAPITrashNoToken(t *testing.T) { fx, _ := trashFixture(t, "code-trash-anon") for _, verb := range []string{"delete", "restore"} { status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/"+verb, "", "") if status != http.StatusUnauthorized { t.Fatalf("%s with no token = %d %s, want 401", verb, status, body) } if !strings.Contains(string(body), "carlos auth login") { t.Fatalf("%s with no token = %s, want cliUser's own wording", verb, body) } } if tomb := tombstone(t, fx, "titogo"); tomb != nil { t.Fatalf("an anonymous delete wrote a marker: %+v", tomb) } } // TestAPICreateAppHappy: `carlos apps create` claims a name through the same // claimApp the browser form uses — the row exists afterwards (so every other // API route's HasAppIn gate opens), the audit row carries the parent spec's // "(cli)" suffix, and the name is normalized exactly as the browser door // normalizes it. func TestAPICreateAppHappy(t *testing.T) { fx := newConsoleFixture(t) if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } token := cliToken(t, fx, "alice@keymail.dev", "code-create-alice") status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": " Paul "}, token) if status != http.StatusOK { t.Fatalf("create = %d %v", status, out) } if str(t, out, "account") != "bab" || str(t, out, "app") != "paul" { t.Fatalf("create body = %v", out) } if got, want := str(t, out, "url"), fx.ts.URL+"/a/bab/apps/paul"; got != want { t.Fatalf("url = %q, want %q", got, want) } if has, err := fx.st.HasAppIn(OpsAccountID, "paul"); err != nil || !has { t.Fatalf("HasAppIn(paul) = %v %v, want true", has, err) } events, err := fx.st.Events(OpsAccountID, "paul", 10) if err != nil || len(events) != 1 { t.Fatalf("events = %v %v, want exactly one", events, err) } if e := events[0]; e.Action != "create" || e.Address != "alice@keymail.dev" || e.Detail != "created app paul (cli)" { t.Fatalf("audit row = %+v", e) } // A second claim of the same name is the ordinary duplicate refusal. status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "paul"}, token) if status != http.StatusConflict { t.Fatalf("duplicate create = %d %v, want 409", status, out) } } // TestAPICreateAppWithPlace: `carlos apps create -place fleet/` (the // 2026-08-06 ruling) stores the placement on the app record at create time // — the store-of-record appPlacement now consults FIRST, which is what // makes this work for an app with zero routes (see // TestAPIInstanceCreateInheritsRouteLessPlacedApp, apiinstances_test.go). func TestAPICreateAppWithPlace(t *testing.T) { fx := newConsoleFixture(t) token := cliToken(t, fx, "paul@keymail.dev", "code-create-place") if status, body := postJSON(t, fx.ts.URL+"/api/cli/fleets/bab/new", map[string]string{"name": "pi", "bucket": "pi-fleet-data"}, token); status != http.StatusOK { t.Fatalf("create fleet = %d %v, want 200", status, body) } status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "titogo", "place": "fleet/pi"}, token) if status != http.StatusOK { t.Fatalf("create with place = %d %v, want 200", status, out) } // The response ECHOES what actually stuck (the 2026-08-06 review's // observability fix) — the CLI (runAppsCreate) compares this field // against what it asked for, so it must carry the real value, not just // a boolean. if got := str(t, out, "placement"); got != "fleet/pi" { t.Fatalf("response placement = %q, want the echoed fleet/pi", got) } p, err := fx.st.AppPlacement(OpsAccountID, "titogo") if err != nil || p != "fleet/pi" { t.Fatalf("AppPlacement after create-with-place = %q %v, want fleet/pi", p, err) } // Clearing side: a create with NO place at all still leaves the app // unplaced, exactly as before this field existed — and the echo agrees. status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "unplaced"}, token) if status != http.StatusOK { t.Fatalf("plain create = %d %v, want 200", status, out) } if got := str(t, out, "placement"); got != "" { t.Fatalf("response placement = %q, want \"\" for a plain create", got) } if p, err := fx.st.AppPlacement(OpsAccountID, "unplaced"); err != nil || p != "" { t.Fatalf("AppPlacement of a plain create = %q %v, want \"\"", p, err) } } // TestClaimAppPlacementWriteFailureDoesNotUndoClaim exercises claimApp's // "the claim succeeded but the placement write did not" branch directly — // the observability fix a 2026-08-06 review required after finding the // create path printed a guarantee ("placed on fleet/pi") it never verified. // Server.store is a concrete *Store (no interface seam to swap in a // failing double), so the placement column itself is dropped after the // fixture's normal migration has already created it — CreateAppIn (which // never touches that column) keeps working, and SetAppPlacement now fails // on every call, which is exactly the scenario claimApp's `placed` return // (and handleAPICreateApp's echo, tested at the HTTP layer above) exist to // make observable instead of silently trusted. func TestClaimAppPlacementWriteFailureDoesNotUndoClaim(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.db.Exec(`ALTER TABLE apps DROP COLUMN placement`); err != nil { t.Fatalf("drop placement column (test setup): %v", err) } acct := &Account{ID: OpsAccountID, Sqid: fx.sqid} outcome, placed, _ := fx.srv.claimApp(context.Background(), acct, "titogo", "paul@keymail.dev", "fleet/pi", false, regionReq{}) if outcome != claimOK { t.Fatalf("claimApp outcome = %v, want claimOK — the claim itself must still succeed", outcome) } if placed != "" { t.Fatalf("claimApp placed = %q, want \"\" — the placement write failed and must not be reported as having stuck", placed) } if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || !has { t.Fatalf("HasAppIn after a failed placement write = %v %v, want true — the claim must not be undone", has, err) } } // TestAPICreateAppWithInvalidPlaceShape400: a place value that doesn't parse // as "fleet/" is a plain 400 — the same grammar check `carlos apps // place` applies to its target — and, critically, the app is NOT created: // create-with-place must not be weaker than place, and it must not be // stronger in the other direction either (a bad place silently ignored, // leaving an app the caller believes is placed). func TestAPICreateAppWithInvalidPlaceShape400(t *testing.T) { fx := newConsoleFixture(t) token := cliToken(t, fx, "paul@keymail.dev", "code-create-place-badshape") status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "titogo", "place": "eu"}, token) if status != http.StatusBadRequest { t.Fatalf("create with a bare region code place = %d %v, want 400", status, out) } if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || has { t.Fatalf("HasAppIn after a refused place = %v %v, want false — nothing should have been created", has, err) } } // TestAPICreateAppWithForeignPlace404: a place value naming a fleet owned by // a DIFFERENT account is the same byte-identical 404 apiFleet and // handleAPIPlace's own target check give everywhere else — and, again, the // app is not created. Creating with a foreign place must refuse exactly as // hard as `carlos apps place` onto the same foreign fleet does. func TestAPICreateAppWithForeignPlace404(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() token := cliToken(t, fx, "paul@keymail.dev", "code-create-place-foreign") if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil { t.Fatal(err) } bobToken := cliToken(t, fx, "bob@keymail.dev", "code-create-place-foreign-bob") if status, body := postJSON(t, fx.ts.URL+"/api/cli/fleets/cdef/new", map[string]string{"name": "acme", "bucket": "bob-data"}, bobToken); status != http.StatusOK { t.Fatalf("bob's fleet create = %d %v, want 200", status, body) } want := canonicalNotFound(t) status, h, out := rawReq(t, http.MethodPost, fx.ts.URL+"/api/cli/apps/bab/new", token, `{"name":"titogo","place":"fleet/acme"}`) assertNotFound(t, "create with a foreign-owned place target", want, status, h, out) if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || has { t.Fatalf("HasAppIn after a refused foreign place = %v %v, want false", has, err) } } // TestAPICreateAppRefusalMatrix: the doors that must NOT open, and the shapes // of their refusals. The one that matters most is byte-identical 404s — an // account that doesn't exist and an account the token's holder doesn't belong // to must be indistinguishable, exactly as they are on every other API route. func TestAPICreateAppRefusalMatrix(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() // bac: somebody else's account. 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) } token := cliToken(t, fx, "alice@keymail.dev", "code-create-refusals") if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "x"}, ""); status != http.StatusUnauthorized { t.Fatalf("no token = %d %v, want 401", status, out) } stTheirs, bodyTheirs := postRaw(t, fx.ts.URL+"/api/cli/apps/bac/new", map[string]string{"name": "fresh"}, token) stUnknown, bodyUnknown := postRaw(t, fx.ts.URL+"/api/cli/apps/zzz/new", map[string]string{"name": "fresh"}, token) if stTheirs != http.StatusNotFound || stUnknown != http.StatusNotFound { t.Fatalf("someone-else's = %d, unknown = %d, want 404 and 404", stTheirs, stUnknown) } if bodyTheirs != bodyUnknown { t.Fatalf("404 bodies differ:\n%q\n%q", bodyTheirs, bodyUnknown) } if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "9lives"}, token); status != http.StatusBadRequest { t.Fatalf("invalid name = %d %v, want 400", status, out) } // "new" is the literal name the console's own routes shadow — claimApp's // claimReserved leg, refused before any store read/write. if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "new"}, token); status != http.StatusConflict { t.Fatalf("reserved name = %d %v, want 409", status, out) } else if !strings.Contains(str(t, out, "error"), "reserved") { t.Fatalf("reserved name error = %v, want it to say reserved", out) } // kass is the fixture's already-claimed app. if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "kass"}, token); status != http.StatusConflict { t.Fatalf("claimed name = %d %v, want 409", status, out) } // A CLI-granted app stays claim-proof in the ops account. if err := fx.st.Grant("bob@keymail.dev", "granted"); err != nil { t.Fatal(err) } if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "granted"}, token); status != http.StatusConflict { t.Fatalf("granted name = %d %v, want 409", status, out) } // So does a name whose objects already sit in the release store. if _, err := fx.rel.Put(context.Background(), release.AppPrefix("bab", "shipped")+"releases/0.0.1/manifest.json", []byte("{}"), ""); err != nil { t.Fatal(err) } if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "shipped"}, token); status != http.StatusConflict { t.Fatalf("bucket-held name = %d %v, want 409", status, out) } // An ambiguous account NAME is refused, not guessed at — the same 409 // every other route gives, via the shared apiAccount. if err := fx.st.CreateAccount(3, "bad", "ops", "alice@keymail.dev", now); err != nil { t.Fatal(err) } status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/ops/new", map[string]string{"name": "fresh"}, token) if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), "ambiguous") { t.Fatalf("ambiguous account = %d %v, want 409 naming the ambiguity", status, out) } } // TestAPICreateAppRateLimit: the create budget is its own (not `write`'s), // keyed by address, and spent per ATTEMPT — refused probes count, or the // limiter would bound nothing an attacker cares about. func TestAPICreateAppRateLimit(t *testing.T) { fx := newConsoleFixture(t) if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } token := cliToken(t, fx, "alice@keymail.dev", "code-create-limit") for i := 0; i < 3; i++ { // refused probes spend budget too if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "9bad"}, token); status != http.StatusBadRequest { t.Fatalf("probe %d = %d %v, want 400", i, status, out) } } for i := 0; i < cliCreateBurst-3; i++ { name := fmt.Sprintf("app-%d", i) if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": name}, token); status != http.StatusOK { t.Fatalf("create %s = %d %v", name, status, out) } } status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "one-too-many"}, token) if status != http.StatusTooManyRequests { t.Fatalf("over budget = %d %v, want 429", status, out) } if has, err := fx.st.HasAppIn(OpsAccountID, "one-too-many"); err != nil || has { t.Fatalf("over-budget name claimed anyway: %v %v", has, err) } // The budget belongs to the ADDRESS, not the token: a second token for // the same address inherits the spent budget — re-minting tokens must // not be a reset button. token2 := cliToken(t, fx, "alice@keymail.dev", "code-create-limit-2") if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "second-token"}, token2); status != http.StatusTooManyRequests { t.Fatalf("second token, same address = %d %v, want 429", status, out) } // ...while a different address's budget is its own. if err := fx.st.AddMember(OpsAccountID, "bob@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } bobToken := cliToken(t, fx, "bob@keymail.dev", "code-create-limit-bob") if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "bob-fresh"}, bobToken); status != http.StatusOK { t.Fatalf("fresh address = %d %v, want 200", status, out) } // The browser door is NOT behind this budget: the same address, its // budget spent, still claims through the console form. browser := newBrowser(t) fx.setUser("alice@keymail.dev") signUp(t, fx, browser, "code-create-limit-browser") postForm(t, browser, fx.u("/apps/new"), url.Values{"name": {"via-browser"}, "csrf": {sessionToken(t, fx, browser)}}) if has, err := fx.st.HasAppIn(OpsAccountID, "via-browser"); err != nil || !has { t.Fatalf("browser claim after CLI budget spent = %v %v, want true", has, err) } } // TestAPIShipEchoesTheBuildProbe: `carlos deploy` learns the app's build // probe from the ship it was already making — no second door, no second // round trip — so the watch can poll the app's own build report instead of // the edge's route-row header. It has to ride BOTH ship answers: a repeated // deploy of bytes already shipped short-circuits to 200, and that deploy // still has a watch to run. func TestAPIShipEchoesTheBuildProbe(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "console", "paul@keymail.dev", now); err != nil { t.Fatal(err) } if err := fx.st.SetAppBuildProbe(OpsAccountID, "console", "/healthz"); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-ship-probe") payload := randomBytes(t, 256) parts := []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}} status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token, parts) if status != http.StatusCreated { t.Fatalf("ship = %d %v", status, out) } if out["build_probe"] != "/healthz" { t.Fatalf("ship body build_probe = %v, want /healthz — the deploy watch has no other way to learn it", out["build_probe"]) } status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token, []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}}) if status != http.StatusOK { t.Fatalf("re-ship of identical bytes = %d %v, want 200", status, out) } if out["build_probe"] != "/healthz" { t.Fatalf("no-op ship body build_probe = %v, want /healthz (that deploy still watches)", out["build_probe"]) } } // TestAPIShipOmitsTheBuildProbeWhenNoneIsDeclared: every ship the fleet does // today answers byte for byte as it did — the key is absent, and a CLI // decoding it gets "" and keeps the header watch. func TestAPIShipOmitsTheBuildProbeWhenNoneIsDeclared(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-ship-noprobe") status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: randomBytes(t, 256)}}) if status != http.StatusCreated { t.Fatalf("ship = %d %v", status, out) } if _, present := out["build_probe"]; present { t.Fatalf("ship body carries build_probe for an app that declares none: %v", out) } } // TestAPIShipRefusesAnUnreadableAppRow pins the ruling the ship door's ONE // app-row read follows: it fails CLOSED. // // This test used to assert the opposite (TestAPIShipSurvivesAnUnreadableBuildProbe, // task 6): the build-probe echo read the row AFTER release.Ship had stored the // bytes, and there the only honest answer was to degrade — refusing a // successful upload over a metadata read would have been a lie about what // happened. That premise is gone. The control-plane checks need the same row // BEFORE a byte is written, so the read moved up and the two columns are read // once, together; and an unreadable row is now a question the guard cannot // answer, on the one app where the wrong answer ships an amd64 binary to a // Graviton box. Refusing costs the caller a retry and leaves nothing // half-done. // // The column is dropped rather than the database closed: closing it would // fail the token lookup and the app 404 long before the ship handler reached // this read. Dropping ONE column leaves every earlier gate working and fails // exactly AppFlags. func TestAPIShipRefusesAnUnreadableAppRow(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "console", "paul@keymail.dev", now); err != nil { t.Fatal(err) } if err := fx.st.SetAppBuildProbe(OpsAccountID, "console", "/healthz"); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-ship-probe-blip") if _, err := fx.st.db.Exec(`ALTER TABLE apps DROP COLUMN build_probe`); err != nil { t.Fatalf("drop the probe column: %v", err) } status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token, []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: randomBytes(t, 256)}}) if status != http.StatusServiceUnavailable { t.Fatalf("ship with an unreadable app row = %d %v, want 503 — the flag check cannot fall through", status, out) } if msg := str(t, out, "error"); !strings.Contains(msg, "nothing was shipped") { t.Fatalf("refusal %q does not tell the caller the upload did not land", msg) } // And it really did not land: a refusal that stores the release anyway is // the worst of both answers. if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "console", "abc1234"); err == nil { t.Fatal("the release was stored despite the 503") } } // TestAPIPromoteRetryResignsThePointer: `carlos promote --retry` over the API // door re-signs a channel's current promotion with a fresh PromotedAt, so a // box that refused that promotion tries it again. The version never moves. func TestAPIPromoteRetryResignsThePointer(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-retry") url := fx.ts.URL + "/api/cli/apps/bab/titogo/promote" if status, out := postJSON(t, url, map[string]string{"version": "abc1234", "channel": "canary/x"}, token); status != http.StatusOK { t.Fatalf("first promote = %d %v", status, out) } before := fx.channelVersion(t, "titogo", "canary/x") time.Sleep(1100 * time.Millisecond) // PromotedAt is truncated to the second status, out := postJSON(t, url, map[string]any{"version": "abc1234", "channel": "canary/x", "retry": true}, token) if status != http.StatusOK { t.Fatalf("retry = %d %v, want 200", status, out) } after := fx.channelVersion(t, "titogo", "canary/x") if after.Version != "abc1234" || !after.PromotedAt.After(before.PromotedAt) { t.Fatalf("pointer after retry = %+v, want abc1234 with a later PromotedAt than %v", after, before.PromotedAt) } } // Ledger Task 7: --retry buys nothing past a deploy credential's scope. A // retry onto a channel the credential cannot promote to gets exactly the // plain promote's refusal, and the pointer is not re-signed. func TestAPIPromoteRetryMeetsTheDeployScope(t *testing.T) { fx, _, deploy := deployFixture(t) seedChannel(t, fx, "hello", "beta", "v1") before := fx.channelVersion(t, "hello", "beta") url := fx.ts.URL + "/api/cli/apps/bab/hello/promote" plainStatus, plain := postJSON(t, url, map[string]any{"version": "v1", "channel": "beta"}, deploy) retryStatus, retry := postJSON(t, url, map[string]any{"version": "v1", "channel": "beta", "retry": true}, deploy) if plainStatus != http.StatusForbidden || retryStatus != plainStatus { t.Fatalf("plain = %d %v, retry = %d %v; want the same 403", plainStatus, plain, retryStatus, retry) } if plain["error"] != retry["error"] || !strings.Contains(str(t, retry, "error"), "beta is not one of them") { t.Fatalf("retry refusal %q, plain %q: want the identical D22 text", retry["error"], plain["error"]) } if after := fx.channelVersion(t, "hello", "beta"); !after.PromotedAt.Equal(before.PromotedAt) { t.Fatalf("beta re-signed at %v (was %v) by an out-of-scope retry", after.PromotedAt, before.PromotedAt) } }