package console // The CLI's pipeline API (apipipeline.go): `carlos pipeline` reading and // writing an app's pipeline object through the console — release pipelines // v2 (docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md). The // properties that matter, in the order they'd hurt if lost: a fresh app // gets the one-channel edge default with no separate call, GET tells // "absent" (null) apart from "unreadable" (502), a write that declares any // nonzero bake flips the legacy production flag ON and never back off, no // channel's approvals may exceed the account's own member count, and — until // Task 8 builds the real quorum flow — a PUT can never silently edit past a // channel the STORED pipeline already guards with change_approvals. import ( "context" "net/http" "strings" "testing" "time" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/svcrole" ) func TestAPIPipelineRoundTripAndDefaults(t *testing.T) { fx := newConsoleFixture(t) token := cliToken(t, fx, "paul@keymail.dev", "code-pipeline-create") // 1. Create an app through the CLI door -> GET pipeline returns the // one-channel edge default (spec §2), not null. status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "widget"}, token) if status != http.StatusOK { t.Fatalf("create app = %d %v", status, out) } status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", token) if status != http.StatusOK { t.Fatalf("pipeline get (fresh app) = %d %v", status, out) } p, _ := out["pipeline"].(map[string]any) if p == nil { t.Fatalf("pipeline (fresh app) = %v, want the default pipeline, not null", out["pipeline"]) } chans, _ := p["channels"].([]any) if len(chans) != 1 { t.Fatalf("default pipeline channels = %v, want exactly one (edge)", chans) } ch0, _ := chans[0].(map[string]any) if str(t, ch0, "name") != "edge" { t.Fatalf("default pipeline channel[0] = %v, want name edge", ch0) } // 2. PUT a two-channel pipeline with a nonzero bake -> 200; GET reflects // it; control/serial bumps. before := serial(t, fx) status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", map[string]any{"channels": []map[string]any{ {"name": "edge"}, {"name": "production", "bake": "24h"}, }}, token) if status != http.StatusOK { t.Fatalf("pipeline put = %d %v", status, out) } if got := serial(t, fx); got != before+1 { t.Fatalf("serial after pipeline put = %d, want %d", got, before+1) } status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", token) if status != http.StatusOK { t.Fatalf("pipeline get after put = %d %v", status, out) } p, _ = out["pipeline"].(map[string]any) chans, _ = p["channels"].([]any) if len(chans) != 2 { t.Fatalf("pipeline after put channels = %v, want 2", chans) } ch1, _ := chans[1].(map[string]any) if str(t, ch1, "name") != "production" || str(t, ch1, "bake") != "24h" { t.Fatalf("pipeline after put channel[1] = %v, want production/24h", ch1) } // 3. The app's production flag is now TRUE (side-write on nonzero bake, // spec §3); a PUT with all-zero bakes must NOT flip it back. ctx := context.Background() flag, err := release.GetProduction(ctx, fx.rel, fx.pub, "bab", "widget") if err != nil || flag == nil || !flag.Production { t.Fatalf("production flag after a pipelined bake = %v, %v, want true", flag, err) } status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", map[string]any{"channels": []map[string]any{{"name": "edge"}}}, token) if status != http.StatusOK { t.Fatalf("all-zero-bake pipeline put = %d %v", status, out) } flag, err = release.GetProduction(ctx, fx.rel, fx.pub, "bab", "widget") if err != nil || flag == nil || !flag.Production { t.Fatalf("production flag after an all-zero-bake put = %v, %v, want still true", flag, err) } // 4. PUT with rules {"promote_approvals": 2} on a 1-member account -> // 422, body names the member count. status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", map[string]any{"channels": []map[string]any{ {"name": "edge"}, {"name": "production", "rules": map[string]any{"promote_approvals": 2}}, }}, token) if status != http.StatusUnprocessableEntity { t.Fatalf("over-quorum pipeline put = %d %v, want 422", status, out) } if !strings.Contains(str(t, out, "error"), "1 member") { t.Fatalf("over-quorum error = %v, want it to name the member count", out["error"]) } // 5. PUT with a channel named "canary" -> 422. status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", map[string]any{"channels": []map[string]any{{"name": "canary"}}}, token) if status != http.StatusUnprocessableEntity { t.Fatalf("canary pipeline put = %d %v, want 422", status, out) } } // TestAPIPipelineGetUnreadableIsNot502 pins the exact trap Task 1's review // caught for GetPipeline itself: a MISSING pipeline is (nil, nil) — the 200 // null case above — but an object that exists and fails verification is an // ERROR, and this door must answer 502 for it, never quietly fold it into // "absent". func TestAPIPipelineGetUnreadableIsNot502(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } // Garbage at the pipeline key: present, but unverifiable. 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-pipeline-unreadable") status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", token) if status != http.StatusBadGateway { t.Fatalf("pipeline get on an unreadable object = %d %v, want 502", status, out) } } // TestAPIPipelinePutGuardedChannelProposes: a PUT that edits, removes or // reorders a channel whose CURRENT stored rules already carry // change_approvals >= 1 does not land — it becomes a pending change (202) // for the members to confirm in the console, which is the exact loophole // spec §2 calls out ("removing the channel or moving its position ... counts // as an edit to its entry"). Adding a guard where there was none is a // tightening edit against an UNGUARDED channel and goes straight through. // // The refusal this replaces was Task 6's interim 403, deliberately temporary // until the pending-change flow existed; what is pinned here — that no // guarded edit ever lands from a bearer token alone — is unchanged. func TestAPIPipelinePutGuardedChannelProposes(t *testing.T) { fx := newConsoleFixture(t) // A second member so a change_approvals of 2 (the re-tightening case // below) is not itself refused by the member-count ceiling — this test // is about the guard, not that unrelated gate. if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-pipeline-guard") status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "widget"}, token) if status != http.StatusOK { t.Fatalf("create app = %d %v", status, out) } // Setting the guard in the first place is a tightening edit against the // pipeline-less default (no channel is guarded yet) — it must go // straight through with no confirmation. status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", map[string]any{"channels": []map[string]any{ {"name": "edge"}, {"name": "production", "rules": map[string]any{"change_approvals": 1}}, }}, token) if status != http.StatusOK { t.Fatalf("set the guard = %d %v", status, out) } // Editing the guarded channel's bake — leaving its own guard untouched — // proposes: 202, naming the quorum, at zero confirmations, pointing at // the console where the passkeys are. status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", map[string]any{"channels": []map[string]any{ {"name": "edge"}, {"name": "production", "bake": "1h", "rules": map[string]any{"change_approvals": 1}}, }}, token) if status != http.StatusAccepted { t.Fatalf("guarded edit = %d %v, want 202", status, out) } if str(t, out, "result") != "proposed" || str(t, out, "pending_id") == "" { t.Fatalf("guarded edit = %v, want a proposal with a pending_id", out) } if num(t, out, "needed") != 1 || num(t, out, "confirmed") != 0 { t.Fatalf("guarded edit = %v, want needed 1 / confirmed 0 — a token never counts", out) } if !strings.Contains(str(t, out, "console_url"), "/a/bab/apps/widget") { t.Fatalf("guarded edit console_url = %v, want the app page", out["console_url"]) } // Removing the guarded channel proposes the same way. status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", map[string]any{"channels": []map[string]any{{"name": "edge"}}}, token) if status != http.StatusAccepted { t.Fatalf("removing the guarded channel = %d %v, want 202", status, out) } // Reordering it proposes the same way. status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", map[string]any{"channels": []map[string]any{ {"name": "production", "rules": map[string]any{"change_approvals": 1}}, {"name": "edge"}, }}, token) if status != http.StatusAccepted { t.Fatalf("reordering the guarded channel = %d %v, want 202", status, out) } // Re-tightening it further (raising its OWN approvals count) proposes // too — the CURRENT stored rule is what gates, not the proposed one. status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", map[string]any{"channels": []map[string]any{ {"name": "edge"}, {"name": "production", "rules": map[string]any{"change_approvals": 2}}, }}, token) if status != http.StatusAccepted { t.Fatalf("re-tightening the guarded channel = %d %v, want 202", status, out) } // None of the proposals moved the stored pipeline: a proposal is a row in // the console's database, never a write to the bucket. status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", token) if status != http.StatusOK { t.Fatalf("pipeline get = %d %v", status, out) } p, _ := out["pipeline"].(map[string]any) chans, _ := p["channels"].([]any) if len(chans) != 2 { t.Fatalf("pipeline after refused edits = %v, want unchanged at 2 channels", chans) } } // TestAPIPipelinePutAuditsSubstanceAndProductionSideWrite: two audit // findings from review. First, the pipeline write's own row must name // substance (channel names and count), not a bare "set the pipeline" that // forces a reader to open the bucket object to see what changed. Second, // the production-flag side-write (spec §3's old-binary fallback) is a real // SetProduction call — every OTHER call site in this codebase // (server.go's enable_bake and production-toggle doors) writes its own // audit row when it flips the flag, and this one must too: the flag gates // fleet-wide baking and delete protection, so a silent flip here would be // the one path to it nothing in the audit trail explains. func TestAPIPipelinePutAuditsSubstanceAndProductionSideWrite(t *testing.T) { fx := newConsoleFixture(t) token := cliToken(t, fx, "paul@keymail.dev", "code-pipeline-audit") if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } status, out := putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", map[string]any{"channels": []map[string]any{ {"name": "edge"}, {"name": "production", "bake": "24h"}, }}, token) if status != http.StatusOK { t.Fatalf("pipeline put = %d %v", status, out) } events, err := fx.st.Events(OpsAccountID, "widget", 10) if err != nil { t.Fatal(err) } var pipelineRow, productionRow *Event for i := range events { switch events[i].Action { case "pipeline": pipelineRow = &events[i] case "production": productionRow = &events[i] } } if pipelineRow == nil { t.Fatalf("events = %v, want a pipeline audit row", events) } if !strings.Contains(pipelineRow.Detail, "edge") || !strings.Contains(pipelineRow.Detail, "production") || !strings.Contains(pipelineRow.Detail, "2 channel") { t.Fatalf("pipeline audit detail = %q, want it to name the channels and count", pipelineRow.Detail) } if productionRow == nil { t.Fatalf("events = %v, want a production audit row for the side-write", events) } if !strings.Contains(productionRow.Detail, "on") { t.Fatalf("production audit detail = %q, want it to say the flag went on", productionRow.Detail) } // A second PUT that declares no bake at all must not flip production // again — and therefore must not write a second production audit row. status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", map[string]any{"channels": []map[string]any{{"name": "edge"}}}, token) if status != http.StatusOK { t.Fatalf("all-zero-bake pipeline put = %d %v", status, out) } events, err = fx.st.Events(OpsAccountID, "widget", 10) if err != nil { t.Fatal(err) } productionRows := 0 for _, e := range events { if e.Action == "production" { productionRows++ } } if productionRows != 1 { t.Fatalf("production audit rows after an all-zero-bake put = %d, want still 1 (never re-declared)", productionRows) } } // pipelineCASStore forces SetPipeline's own conditional Put on pipeline.json // to lose the race — the same shape casStore (apiledger_test.go) uses for // ledger keys — simulating a concurrent pipeline write without needing two // real goroutines to land in the narrow window between SetPipeline's own // read and its write. type pipelineCASStore struct{ release.Store } func (c pipelineCASStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { if strings.HasSuffix(key, "pipeline.json") { return "", release.ErrCASConflict } return c.Store.Put(ctx, key, body, ifMatch) } // TestAPIPipelinePutStoreFailureStatuses: SetPipeline's non-validation // failures must not be echoed wholesale — a lost CAS race is retry-worthy // (409, a fixed sentence), and a broken store is not the caller's fault and // must never put the store's own error text (bucket names, request ids) on // the wire (502, generic). func TestAPIPipelinePutStoreFailureStatuses(t *testing.T) { t.Run("concurrent write is 409", func(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-pipeline-cas") fx.srv.cfg.Releases = pipelineCASStore{fx.rel} status, out := putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", map[string]any{"channels": []map[string]any{{"name": "edge"}}}, token) if status != http.StatusConflict { t.Fatalf("pipeline put racing a concurrent write = %d %v, want 409", status, out) } if !strings.Contains(str(t, out, "error"), "re-read and retry") { t.Fatalf("cas conflict error = %v, want it to say re-read and retry", out["error"]) } }) t.Run("broken store is 502 and never leaks its error text", func(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-pipeline-broken") fx.srv.cfg.Releases = brokenStore{fx.rel} status, out := putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", map[string]any{"channels": []map[string]any{{"name": "edge"}}}, token) if status != http.StatusBadGateway { t.Fatalf("pipeline put against a broken store = %d %v, want 502", status, out) } if strings.Contains(str(t, out, "error"), "AccessDenied") || strings.Contains(str(t, out, "error"), "carlos-prod-secret-bucket") { t.Fatalf("broken-store error leaked the store's own text: %v", out["error"]) } }) } // TestAPIPipelinePutNoServiceRoleReachesIt: svcrole.Allow() with NO roles is // the fully-closed Set — Admin included (svcrole.TestEmptySetPermitsNothingIncludingAdmin) // — so the door must be registered svcrole.Allow(svcrole.Admin), not // svcrole.Allow(). This pins the door's actual behaviour rather than the // registration line, so a future edit that regresses to the empty form // breaks a test instead of silently locking every service out. func TestAPIPipelinePutNoServiceRoleReachesIt(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } admin := serviceToken(t, fx.st, OpsAccountID, "deploybot", "widget", svcrole.Admin) publish := serviceToken(t, fx.st, OpsAccountID, "publishbot", "widget", svcrole.Publish) status, out := putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", map[string]any{"channels": []map[string]any{{"name": "edge"}}}, admin) if status != http.StatusOK { t.Fatalf("admin service pipeline put = %d %v, want 200", status, out) } status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", map[string]any{"channels": []map[string]any{{"name": "edge"}}}, publish) if status != http.StatusForbidden { t.Fatalf("publish-role service pipeline put = %d %v, want 403", status, out) } }