carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package 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
14import (
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
25func 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".
124func 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.
152func 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.
251func 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.
321type pipelineCASStore struct{ release.Store }
322
323func (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).
335func 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.
380func 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