| 1 | package console |
| 2 | |
| 3 | // The CLI's pipeline API (apipipeline.go): `carlos pipeline` reading and |
| 4 | // writing an app's pipeline object through the console — release pipelines |
| 5 | // v2 (docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md). The |
| 6 | // properties that matter, in the order they'd hurt if lost: a fresh app |
| 7 | // gets the one-channel edge default with no separate call, GET tells |
| 8 | // "absent" (null) apart from "unreadable" (502), a write that declares any |
| 9 | // nonzero bake flips the legacy production flag ON and never back off, no |
| 10 | // channel's approvals may exceed the account's own member count, and — until |
| 11 | // Task 8 builds the real quorum flow — a PUT can never silently edit past a |
| 12 | // channel the STORED pipeline already guards with change_approvals. |
| 13 | |
| 14 | import ( |
| 15 | "context" |
| 16 | "net/http" |
| 17 | "strings" |
| 18 | "testing" |
| 19 | "time" |
| 20 | |
| 21 | "github.com/carlosframework/platform/internal/release" |
| 22 | "github.com/carlosframework/platform/internal/svcrole" |
| 23 | ) |
| 24 | |
| 25 | func TestAPIPipelineRoundTripAndDefaults(t *testing.T) { |
| 26 | fx := newConsoleFixture(t) |
| 27 | token := cliToken(t, fx, "paul@keymail.dev", "code-pipeline-create") |
| 28 | |
| 29 | // 1. Create an app through the CLI door -> GET pipeline returns the |
| 30 | // one-channel edge default (spec §2), not null. |
| 31 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "widget"}, token) |
| 32 | if status != http.StatusOK { |
| 33 | t.Fatalf("create app = %d %v", status, out) |
| 34 | } |
| 35 | status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", token) |
| 36 | if status != http.StatusOK { |
| 37 | t.Fatalf("pipeline get (fresh app) = %d %v", status, out) |
| 38 | } |
| 39 | p, _ := out["pipeline"].(map[string]any) |
| 40 | if p == nil { |
| 41 | t.Fatalf("pipeline (fresh app) = %v, want the default pipeline, not null", out["pipeline"]) |
| 42 | } |
| 43 | chans, _ := p["channels"].([]any) |
| 44 | if len(chans) != 1 { |
| 45 | t.Fatalf("default pipeline channels = %v, want exactly one (edge)", chans) |
| 46 | } |
| 47 | ch0, _ := chans[0].(map[string]any) |
| 48 | if str(t, ch0, "name") != "edge" { |
| 49 | t.Fatalf("default pipeline channel[0] = %v, want name edge", ch0) |
| 50 | } |
| 51 | |
| 52 | // 2. PUT a two-channel pipeline with a nonzero bake -> 200; GET reflects |
| 53 | // it; control/serial bumps. |
| 54 | before := serial(t, fx) |
| 55 | status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| 56 | map[string]any{"channels": []map[string]any{ |
| 57 | {"name": "edge"}, |
| 58 | {"name": "production", "bake": "24h"}, |
| 59 | }}, token) |
| 60 | if status != http.StatusOK { |
| 61 | t.Fatalf("pipeline put = %d %v", status, out) |
| 62 | } |
| 63 | if got := serial(t, fx); got != before+1 { |
| 64 | t.Fatalf("serial after pipeline put = %d, want %d", got, before+1) |
| 65 | } |
| 66 | status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", token) |
| 67 | if status != http.StatusOK { |
| 68 | t.Fatalf("pipeline get after put = %d %v", status, out) |
| 69 | } |
| 70 | p, _ = out["pipeline"].(map[string]any) |
| 71 | chans, _ = p["channels"].([]any) |
| 72 | if len(chans) != 2 { |
| 73 | t.Fatalf("pipeline after put channels = %v, want 2", chans) |
| 74 | } |
| 75 | ch1, _ := chans[1].(map[string]any) |
| 76 | if str(t, ch1, "name") != "production" || str(t, ch1, "bake") != "24h" { |
| 77 | t.Fatalf("pipeline after put channel[1] = %v, want production/24h", ch1) |
| 78 | } |
| 79 | |
| 80 | // 3. The app's production flag is now TRUE (side-write on nonzero bake, |
| 81 | // spec §3); a PUT with all-zero bakes must NOT flip it back. |
| 82 | ctx := context.Background() |
| 83 | flag, err := release.GetProduction(ctx, fx.rel, fx.pub, "bab", "widget") |
| 84 | if err != nil || flag == nil || !flag.Production { |
| 85 | t.Fatalf("production flag after a pipelined bake = %v, %v, want true", flag, err) |
| 86 | } |
| 87 | status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| 88 | map[string]any{"channels": []map[string]any{{"name": "edge"}}}, token) |
| 89 | if status != http.StatusOK { |
| 90 | t.Fatalf("all-zero-bake pipeline put = %d %v", status, out) |
| 91 | } |
| 92 | flag, err = release.GetProduction(ctx, fx.rel, fx.pub, "bab", "widget") |
| 93 | if err != nil || flag == nil || !flag.Production { |
| 94 | t.Fatalf("production flag after an all-zero-bake put = %v, %v, want still true", flag, err) |
| 95 | } |
| 96 | |
| 97 | // 4. PUT with rules {"promote_approvals": 2} on a 1-member account -> |
| 98 | // 422, body names the member count. |
| 99 | status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| 100 | map[string]any{"channels": []map[string]any{ |
| 101 | {"name": "edge"}, |
| 102 | {"name": "production", "rules": map[string]any{"promote_approvals": 2}}, |
| 103 | }}, token) |
| 104 | if status != http.StatusUnprocessableEntity { |
| 105 | t.Fatalf("over-quorum pipeline put = %d %v, want 422", status, out) |
| 106 | } |
| 107 | if !strings.Contains(str(t, out, "error"), "1 member") { |
| 108 | t.Fatalf("over-quorum error = %v, want it to name the member count", out["error"]) |
| 109 | } |
| 110 | |
| 111 | // 5. PUT with a channel named "canary" -> 422. |
| 112 | status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| 113 | map[string]any{"channels": []map[string]any{{"name": "canary"}}}, token) |
| 114 | if status != http.StatusUnprocessableEntity { |
| 115 | t.Fatalf("canary pipeline put = %d %v, want 422", status, out) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // TestAPIPipelineGetUnreadableIsNot502 pins the exact trap Task 1's review |
| 120 | // caught for GetPipeline itself: a MISSING pipeline is (nil, nil) — the 200 |
| 121 | // null case above — but an object that exists and fails verification is an |
| 122 | // ERROR, and this door must answer 502 for it, never quietly fold it into |
| 123 | // "absent". |
| 124 | func TestAPIPipelineGetUnreadableIsNot502(t *testing.T) { |
| 125 | fx := newConsoleFixture(t) |
| 126 | if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | // Garbage at the pipeline key: present, but unverifiable. |
| 130 | if _, err := fx.rel.Put(context.Background(), release.PipelineKey("bab", "widget"), []byte("not json"), ""); err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | token := cliToken(t, fx, "paul@keymail.dev", "code-pipeline-unreadable") |
| 134 | |
| 135 | status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", token) |
| 136 | if status != http.StatusBadGateway { |
| 137 | t.Fatalf("pipeline get on an unreadable object = %d %v, want 502", status, out) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // TestAPIPipelinePutGuardedChannelProposes: a PUT that edits, removes or |
| 142 | // reorders a channel whose CURRENT stored rules already carry |
| 143 | // change_approvals >= 1 does not land — it becomes a pending change (202) |
| 144 | // for the members to confirm in the console, which is the exact loophole |
| 145 | // spec §2 calls out ("removing the channel or moving its position ... counts |
| 146 | // as an edit to its entry"). Adding a guard where there was none is a |
| 147 | // tightening edit against an UNGUARDED channel and goes straight through. |
| 148 | // |
| 149 | // The refusal this replaces was Task 6's interim 403, deliberately temporary |
| 150 | // until the pending-change flow existed; what is pinned here — that no |
| 151 | // guarded edit ever lands from a bearer token alone — is unchanged. |
| 152 | func TestAPIPipelinePutGuardedChannelProposes(t *testing.T) { |
| 153 | fx := newConsoleFixture(t) |
| 154 | // A second member so a change_approvals of 2 (the re-tightening case |
| 155 | // below) is not itself refused by the member-count ceiling — this test |
| 156 | // is about the guard, not that unrelated gate. |
| 157 | if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | token := cliToken(t, fx, "paul@keymail.dev", "code-pipeline-guard") |
| 161 | |
| 162 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "widget"}, token) |
| 163 | if status != http.StatusOK { |
| 164 | t.Fatalf("create app = %d %v", status, out) |
| 165 | } |
| 166 | |
| 167 | // Setting the guard in the first place is a tightening edit against the |
| 168 | // pipeline-less default (no channel is guarded yet) — it must go |
| 169 | // straight through with no confirmation. |
| 170 | status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| 171 | map[string]any{"channels": []map[string]any{ |
| 172 | {"name": "edge"}, |
| 173 | {"name": "production", "rules": map[string]any{"change_approvals": 1}}, |
| 174 | }}, token) |
| 175 | if status != http.StatusOK { |
| 176 | t.Fatalf("set the guard = %d %v", status, out) |
| 177 | } |
| 178 | |
| 179 | // Editing the guarded channel's bake — leaving its own guard untouched — |
| 180 | // proposes: 202, naming the quorum, at zero confirmations, pointing at |
| 181 | // the console where the passkeys are. |
| 182 | status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| 183 | map[string]any{"channels": []map[string]any{ |
| 184 | {"name": "edge"}, |
| 185 | {"name": "production", "bake": "1h", "rules": map[string]any{"change_approvals": 1}}, |
| 186 | }}, token) |
| 187 | if status != http.StatusAccepted { |
| 188 | t.Fatalf("guarded edit = %d %v, want 202", status, out) |
| 189 | } |
| 190 | if str(t, out, "result") != "proposed" || str(t, out, "pending_id") == "" { |
| 191 | t.Fatalf("guarded edit = %v, want a proposal with a pending_id", out) |
| 192 | } |
| 193 | if num(t, out, "needed") != 1 || num(t, out, "confirmed") != 0 { |
| 194 | t.Fatalf("guarded edit = %v, want needed 1 / confirmed 0 — a token never counts", out) |
| 195 | } |
| 196 | if !strings.Contains(str(t, out, "console_url"), "/a/bab/apps/widget") { |
| 197 | t.Fatalf("guarded edit console_url = %v, want the app page", out["console_url"]) |
| 198 | } |
| 199 | |
| 200 | // Removing the guarded channel proposes the same way. |
| 201 | status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| 202 | map[string]any{"channels": []map[string]any{{"name": "edge"}}}, token) |
| 203 | if status != http.StatusAccepted { |
| 204 | t.Fatalf("removing the guarded channel = %d %v, want 202", status, out) |
| 205 | } |
| 206 | |
| 207 | // Reordering it proposes the same way. |
| 208 | status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| 209 | map[string]any{"channels": []map[string]any{ |
| 210 | {"name": "production", "rules": map[string]any{"change_approvals": 1}}, |
| 211 | {"name": "edge"}, |
| 212 | }}, token) |
| 213 | if status != http.StatusAccepted { |
| 214 | t.Fatalf("reordering the guarded channel = %d %v, want 202", status, out) |
| 215 | } |
| 216 | |
| 217 | // Re-tightening it further (raising its OWN approvals count) proposes |
| 218 | // too — the CURRENT stored rule is what gates, not the proposed one. |
| 219 | status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| 220 | map[string]any{"channels": []map[string]any{ |
| 221 | {"name": "edge"}, |
| 222 | {"name": "production", "rules": map[string]any{"change_approvals": 2}}, |
| 223 | }}, token) |
| 224 | if status != http.StatusAccepted { |
| 225 | t.Fatalf("re-tightening the guarded channel = %d %v, want 202", status, out) |
| 226 | } |
| 227 | |
| 228 | // None of the proposals moved the stored pipeline: a proposal is a row in |
| 229 | // the console's database, never a write to the bucket. |
| 230 | status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", token) |
| 231 | if status != http.StatusOK { |
| 232 | t.Fatalf("pipeline get = %d %v", status, out) |
| 233 | } |
| 234 | p, _ := out["pipeline"].(map[string]any) |
| 235 | chans, _ := p["channels"].([]any) |
| 236 | if len(chans) != 2 { |
| 237 | t.Fatalf("pipeline after refused edits = %v, want unchanged at 2 channels", chans) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | // TestAPIPipelinePutAuditsSubstanceAndProductionSideWrite: two audit |
| 242 | // findings from review. First, the pipeline write's own row must name |
| 243 | // substance (channel names and count), not a bare "set the pipeline" that |
| 244 | // forces a reader to open the bucket object to see what changed. Second, |
| 245 | // the production-flag side-write (spec §3's old-binary fallback) is a real |
| 246 | // SetProduction call — every OTHER call site in this codebase |
| 247 | // (server.go's enable_bake and production-toggle doors) writes its own |
| 248 | // audit row when it flips the flag, and this one must too: the flag gates |
| 249 | // fleet-wide baking and delete protection, so a silent flip here would be |
| 250 | // the one path to it nothing in the audit trail explains. |
| 251 | func TestAPIPipelinePutAuditsSubstanceAndProductionSideWrite(t *testing.T) { |
| 252 | fx := newConsoleFixture(t) |
| 253 | token := cliToken(t, fx, "paul@keymail.dev", "code-pipeline-audit") |
| 254 | if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { |
| 255 | t.Fatal(err) |
| 256 | } |
| 257 | |
| 258 | status, out := putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| 259 | map[string]any{"channels": []map[string]any{ |
| 260 | {"name": "edge"}, |
| 261 | {"name": "production", "bake": "24h"}, |
| 262 | }}, token) |
| 263 | if status != http.StatusOK { |
| 264 | t.Fatalf("pipeline put = %d %v", status, out) |
| 265 | } |
| 266 | |
| 267 | events, err := fx.st.Events(OpsAccountID, "widget", 10) |
| 268 | if err != nil { |
| 269 | t.Fatal(err) |
| 270 | } |
| 271 | var pipelineRow, productionRow *Event |
| 272 | for i := range events { |
| 273 | switch events[i].Action { |
| 274 | case "pipeline": |
| 275 | pipelineRow = &events[i] |
| 276 | case "production": |
| 277 | productionRow = &events[i] |
| 278 | } |
| 279 | } |
| 280 | if pipelineRow == nil { |
| 281 | t.Fatalf("events = %v, want a pipeline audit row", events) |
| 282 | } |
| 283 | if !strings.Contains(pipelineRow.Detail, "edge") || !strings.Contains(pipelineRow.Detail, "production") || |
| 284 | !strings.Contains(pipelineRow.Detail, "2 channel") { |
| 285 | t.Fatalf("pipeline audit detail = %q, want it to name the channels and count", pipelineRow.Detail) |
| 286 | } |
| 287 | if productionRow == nil { |
| 288 | t.Fatalf("events = %v, want a production audit row for the side-write", events) |
| 289 | } |
| 290 | if !strings.Contains(productionRow.Detail, "on") { |
| 291 | t.Fatalf("production audit detail = %q, want it to say the flag went on", productionRow.Detail) |
| 292 | } |
| 293 | |
| 294 | // A second PUT that declares no bake at all must not flip production |
| 295 | // again — and therefore must not write a second production audit row. |
| 296 | status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| 297 | map[string]any{"channels": []map[string]any{{"name": "edge"}}}, token) |
| 298 | if status != http.StatusOK { |
| 299 | t.Fatalf("all-zero-bake pipeline put = %d %v", status, out) |
| 300 | } |
| 301 | events, err = fx.st.Events(OpsAccountID, "widget", 10) |
| 302 | if err != nil { |
| 303 | t.Fatal(err) |
| 304 | } |
| 305 | productionRows := 0 |
| 306 | for _, e := range events { |
| 307 | if e.Action == "production" { |
| 308 | productionRows++ |
| 309 | } |
| 310 | } |
| 311 | if productionRows != 1 { |
| 312 | t.Fatalf("production audit rows after an all-zero-bake put = %d, want still 1 (never re-declared)", productionRows) |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | // pipelineCASStore forces SetPipeline's own conditional Put on pipeline.json |
| 317 | // to lose the race — the same shape casStore (apiledger_test.go) uses for |
| 318 | // ledger keys — simulating a concurrent pipeline write without needing two |
| 319 | // real goroutines to land in the narrow window between SetPipeline's own |
| 320 | // read and its write. |
| 321 | type pipelineCASStore struct{ release.Store } |
| 322 | |
| 323 | func (c pipelineCASStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 324 | if strings.HasSuffix(key, "pipeline.json") { |
| 325 | return "", release.ErrCASConflict |
| 326 | } |
| 327 | return c.Store.Put(ctx, key, body, ifMatch) |
| 328 | } |
| 329 | |
| 330 | // TestAPIPipelinePutStoreFailureStatuses: SetPipeline's non-validation |
| 331 | // failures must not be echoed wholesale — a lost CAS race is retry-worthy |
| 332 | // (409, a fixed sentence), and a broken store is not the caller's fault and |
| 333 | // must never put the store's own error text (bucket names, request ids) on |
| 334 | // the wire (502, generic). |
| 335 | func TestAPIPipelinePutStoreFailureStatuses(t *testing.T) { |
| 336 | t.Run("concurrent write is 409", func(t *testing.T) { |
| 337 | fx := newConsoleFixture(t) |
| 338 | if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { |
| 339 | t.Fatal(err) |
| 340 | } |
| 341 | token := cliToken(t, fx, "paul@keymail.dev", "code-pipeline-cas") |
| 342 | fx.srv.cfg.Releases = pipelineCASStore{fx.rel} |
| 343 | |
| 344 | status, out := putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| 345 | map[string]any{"channels": []map[string]any{{"name": "edge"}}}, token) |
| 346 | if status != http.StatusConflict { |
| 347 | t.Fatalf("pipeline put racing a concurrent write = %d %v, want 409", status, out) |
| 348 | } |
| 349 | if !strings.Contains(str(t, out, "error"), "re-read and retry") { |
| 350 | t.Fatalf("cas conflict error = %v, want it to say re-read and retry", out["error"]) |
| 351 | } |
| 352 | }) |
| 353 | |
| 354 | t.Run("broken store is 502 and never leaks its error text", func(t *testing.T) { |
| 355 | fx := newConsoleFixture(t) |
| 356 | if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { |
| 357 | t.Fatal(err) |
| 358 | } |
| 359 | token := cliToken(t, fx, "paul@keymail.dev", "code-pipeline-broken") |
| 360 | fx.srv.cfg.Releases = brokenStore{fx.rel} |
| 361 | |
| 362 | status, out := putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| 363 | map[string]any{"channels": []map[string]any{{"name": "edge"}}}, token) |
| 364 | if status != http.StatusBadGateway { |
| 365 | t.Fatalf("pipeline put against a broken store = %d %v, want 502", status, out) |
| 366 | } |
| 367 | if strings.Contains(str(t, out, "error"), "AccessDenied") || |
| 368 | strings.Contains(str(t, out, "error"), "carlos-prod-secret-bucket") { |
| 369 | t.Fatalf("broken-store error leaked the store's own text: %v", out["error"]) |
| 370 | } |
| 371 | }) |
| 372 | } |
| 373 | |
| 374 | // TestAPIPipelinePutNoServiceRoleReachesIt: svcrole.Allow() with NO roles is |
| 375 | // the fully-closed Set — Admin included (svcrole.TestEmptySetPermitsNothingIncludingAdmin) |
| 376 | // — so the door must be registered svcrole.Allow(svcrole.Admin), not |
| 377 | // svcrole.Allow(). This pins the door's actual behaviour rather than the |
| 378 | // registration line, so a future edit that regresses to the empty form |
| 379 | // breaks a test instead of silently locking every service out. |
| 380 | func TestAPIPipelinePutNoServiceRoleReachesIt(t *testing.T) { |
| 381 | fx := newConsoleFixture(t) |
| 382 | if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { |
| 383 | t.Fatal(err) |
| 384 | } |
| 385 | admin := serviceToken(t, fx.st, OpsAccountID, "deploybot", "widget", svcrole.Admin) |
| 386 | publish := serviceToken(t, fx.st, OpsAccountID, "publishbot", "widget", svcrole.Publish) |
| 387 | |
| 388 | status, out := putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| 389 | map[string]any{"channels": []map[string]any{{"name": "edge"}}}, admin) |
| 390 | if status != http.StatusOK { |
| 391 | t.Fatalf("admin service pipeline put = %d %v, want 200", status, out) |
| 392 | } |
| 393 | |
| 394 | status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| 395 | map[string]any{"channels": []map[string]any{{"name": "edge"}}}, publish) |
| 396 | if status != http.StatusForbidden { |
| 397 | t.Fatalf("publish-role service pipeline put = %d %v, want 403", status, out) |
| 398 | } |
| 399 | } |
| 400 | |