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// Per-channel rules at the console's gates, and the pending-change flow they
4// open onto — release pipelines v2 §4/§5. The properties, in the order they
5// would hurt if lost:
6//
7// 1. A channel's declared rules decide what a promotion into it costs: a
8// passkey rule refuses the bearer-token door outright, an approvals rule
9// turns it into a PROPOSAL that has moved nothing.
10// 2. Quorum, once met by real passkey-backed confirmations, is the ONE place
11// the gated write happens — and it happens exactly once.
12// 3. An app with NO pipeline is untouched, byte for byte, including the
13// legacy stable refusal every CLI in the field already parses.
14// 4. A policy proposal pins the pipeline it was proposed against; a pipeline
15// that moved underneath it invalidates it rather than silently landing a
16// stale channel list.
17// 5. -hotfix bypasses ORDER only. It never buys past a rule.
18
19import (
20 "context"
21 "net/http"
22 "net/url"
23 "strings"
24 "testing"
25 "time"
26
27 "github.com/carlosframework/platform/internal/release"
28)
29
30// setPipeline installs an app's pipeline through the real signed primitive —
31// fixture state, arranged the way the console will read it, not a promotion
32// or a policy edit under test.
33func setPipeline(t *testing.T, fx *consoleFixture, app string, channels ...release.PipelineChannel) *release.Pipeline {
34 t.Helper()
35 p, err := release.SetPipeline(context.Background(), fx.rel, fx.priv,
36 release.OpsAccount, app, channels, "fixture")
37 if err != nil {
38 t.Fatal(err)
39 }
40 return p
41}
42
43// pipelinedApp is the world every test below needs: a claimed app, one
44// shipped release, that release sitting on the pipeline's entry channel, and
45// the pipeline itself.
46func pipelinedApp(t *testing.T, fx *consoleFixture, app, version string, channels ...release.PipelineChannel) {
47 t.Helper()
48 if _, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", time.Now()); err != nil {
49 t.Fatal(err)
50 }
51 shipVersion(t, fx, app, version)
52 seedChannel(t, fx, app, "edge", version)
53 setPipeline(t, fx, app, channels...)
54}
55
56// num reads a JSON number off a decoded body — every count on this wire
57// (needed, confirmed) arrives as a float64.
58func num(t *testing.T, m map[string]any, k string) int {
59 t.Helper()
60 v, _ := m[k].(float64)
61 return int(v)
62}
63
64// channelAbsent asserts a channel pointer does not exist — the assertion that
65// makes a refusal true rather than merely spoken.
66func channelAbsent(t *testing.T, fx *consoleFixture, app, channel string) {
67 t.Helper()
68 if _, err := release.Channel(context.Background(), fx.rel, fx.pub, release.OpsAccount, app, channel); err == nil {
69 t.Fatalf("channel %s exists after a refused promotion, want it absent", channel)
70 }
71}
72
73// TestPipelinePromoteRulesLadder: the target channel's OWN rules decide what
74// the CLI door does — refuse, propose, or promote — and neither refusal nor
75// proposal moves anything.
76func TestPipelinePromoteRulesLadder(t *testing.T) {
77 fx := newConsoleFixture(t)
78 // A second member, so promote_approvals: 2 is a quorum this account could
79 // actually reach.
80 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil {
81 t.Fatal(err)
82 }
83 pipelinedApp(t, fx, "widget", "abc1234",
84 release.PipelineChannel{Name: "edge"},
85 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{Passkey: true}})
86 token := cliToken(t, fx, "paul@keymail.dev", "code-rules-ladder")
87 promoteURL := fx.ts.URL + "/api/cli/apps/bab/widget/promote"
88 body := map[string]any{"version": "abc1234", "channel": "production"}
89
90 // A passkey rule: there is no passkey at this door, so it refuses and
91 // names the one that has one.
92 status, out := postJSON(t, promoteURL, body, token)
93 if status != http.StatusForbidden {
94 t.Fatalf("promote into a passkey channel = %d %v, want 403", status, out)
95 }
96 if !strings.Contains(str(t, out, "error"), "production needs a passkey") {
97 t.Fatalf("passkey refusal = %v, want it to name the channel and the passkey", out["error"])
98 }
99 if !strings.Contains(str(t, out, "console_url"), "/a/bab/apps/widget") {
100 t.Fatalf("passkey refusal console_url = %v, want the app page", out["console_url"])
101 }
102 channelAbsent(t, fx, "widget", "production")
103
104 // An approvals rule: the door PROPOSES, at zero confirmations — a bearer
105 // token never counts toward a passkey quorum (spec §4).
106 setPipeline(t, fx, "widget",
107 release.PipelineChannel{Name: "edge"},
108 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}})
109 before := serial(t, fx)
110 status, out = postJSON(t, promoteURL, body, token)
111 if status != http.StatusAccepted {
112 t.Fatalf("promote into an approvals channel = %d %v, want 202", status, out)
113 }
114 if str(t, out, "result") != "proposed" || str(t, out, "pending_id") == "" {
115 t.Fatalf("proposal = %v, want result proposed with a pending_id", out)
116 }
117 if num(t, out, "needed") != 2 || num(t, out, "confirmed") != 0 {
118 t.Fatalf("proposal = %v, want needed 2 / confirmed 0", out)
119 }
120 if !strings.Contains(str(t, out, "console_url"), "/a/bab/apps/widget") {
121 t.Fatalf("proposal console_url = %v, want the app page", out["console_url"])
122 }
123 channelAbsent(t, fx, "widget", "production")
124 if got := serial(t, fx); got != before {
125 t.Fatalf("serial after a proposal = %d, want it unmoved at %d — nothing was written for a box to read", got, before)
126 }
127
128 // And the CLI can see what it proposed.
129 status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pending", token)
130 if status != http.StatusOK {
131 t.Fatalf("pending list = %d %v", status, out)
132 }
133 list, _ := out["pending"].([]any)
134 if len(list) != 1 {
135 t.Fatalf("pending list = %v, want exactly the one proposal", out["pending"])
136 }
137 row, _ := list[0].(map[string]any)
138 if str(t, row, "kind") != "promote" || num(t, row, "needed") != 2 || num(t, row, "confirmed") != 0 {
139 t.Fatalf("pending row = %v, want a promote at 0/2", row)
140 }
141 if !strings.Contains(str(t, row, "summary"), "abc1234") || !strings.Contains(str(t, row, "summary"), "production") {
142 t.Fatalf("pending summary = %q, want it to name the version and the channel", row["summary"])
143 }
144
145 // An UNRULED channel of the same pipeline promotes as it always did.
146 setPipeline(t, fx, "widget",
147 release.PipelineChannel{Name: "edge"},
148 release.PipelineChannel{Name: "production"})
149 status, out = postJSON(t, promoteURL, body, token)
150 if status != http.StatusOK || str(t, out, "result") != "promoted" {
151 t.Fatalf("promote into an unruled channel = %d %v, want a plain 200 promotion", status, out)
152 }
153 if got := fx.channelVersion(t, "widget", "production"); got.Version != "abc1234" {
154 t.Fatalf("production serves %q, want abc1234", got.Version)
155 }
156}
157
158// TestPipelineQuorumExecutesPromotion walks the whole two-member flow in the
159// browser: a passkey-backed proposal counts its proposer, the second member's
160// own passkey meets the quorum, and THAT is where the pointer moves.
161func TestPipelineQuorumExecutesPromotion(t *testing.T) {
162 fx, c, csrf := memberFixture(t) // paul owns, kim is a member
163 pipelinedApp(t, fx, "widget", "abc1234",
164 release.PipelineChannel{Name: "edge"},
165 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}})
166
167 status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/promote"),
168 url.Values{"csrf": {csrf}, "channel": {"production"}, "version": {"abc1234"}, "confirmed": {"1"}})
169 if status != http.StatusSeeOther {
170 t.Fatalf("owner proposal = %d -> %q:\n%s", status, loc, body)
171 }
172 pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
173 if err != nil {
174 t.Fatal(err)
175 }
176 if len(pending) != 1 {
177 t.Fatalf("%d pending changes after a proposal, want 1", len(pending))
178 }
179 pc := pending[0]
180 if pc.Kind != "promote" || pc.Quorum != 2 {
181 t.Fatalf("pending change = %+v, want a promote needing 2", pc)
182 }
183 if len(pc.Confirms) != 1 || pc.Confirms[0] != "paul@keymail.dev" {
184 t.Fatalf("confirmations = %v, want the passkey-backed proposer counted once", pc.Confirms)
185 }
186 channelAbsent(t, fx, "widget", "production")
187
188 // The second member confirms with their OWN passkey.
189 kim := newBrowser(t)
190 fx.setUser("kim@keymail.dev")
191 signUp(t, fx, kim, "code-kim-confirm")
192 kimCSRF := sessionToken(t, fx, kim)
193 before := serial(t, fx)
194 confirmURL := fx.u("/apps/widget/pending/" + pc.ID + "/confirm")
195 status, loc, body = postGated(t, fx, kim, confirmURL, url.Values{"csrf": {kimCSRF}})
196 if status != http.StatusSeeOther {
197 t.Fatalf("second confirmation = %d -> %q:\n%s", status, loc, body)
198 }
199 if got := fx.channelVersion(t, "widget", "production"); got.Version != "abc1234" {
200 t.Fatalf("production serves %q after quorum, want abc1234", got.Version)
201 }
202 if got := serial(t, fx); got != before+1 {
203 t.Fatalf("serial after an executed promotion = %d, want %d", got, before+1)
204 }
205 left, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
206 if err != nil {
207 t.Fatal(err)
208 }
209 if len(left) != 0 {
210 t.Fatalf("%d pending changes after execution, want the executed one gone", len(left))
211 }
212
213 // Confirming it again is the same 404 an id that never existed gets: an
214 // executed change is not pending, and a second confirmation must not be
215 // able to replay the write.
216 status, loc, _ = postForm(t, kim, confirmURL, url.Values{"csrf": {kimCSRF}})
217 if status != http.StatusNotFound {
218 t.Fatalf("re-confirming an executed change = %d -> %q, want 404", status, loc)
219 }
220}
221
222// TestPipelineLegacyAppsUntouched: an app with no pipeline meets exactly
223// today's doors. The stable refusal is the one every CLI in the field parses,
224// it stays byte-identical between a member and an owner, and a hotfix flag
225// buys nothing at it.
226func TestPipelineLegacyAppsUntouched(t *testing.T) {
227 fx := newConsoleFixture(t)
228 now := time.Now()
229 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
230 t.Fatal(err)
231 }
232 shipVersion(t, fx, "titogo", "abc1234")
233 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
234 t.Fatal(err)
235 }
236 owner := cliToken(t, fx, "paul@keymail.dev", "code-legacy-owner")
237 member := cliToken(t, fx, "alice@keymail.dev", "code-legacy-member")
238 promoteURL := fx.ts.URL + "/api/cli/apps/bab/titogo/promote"
239
240 body := map[string]any{"version": "abc1234", "channel": "stable"}
241 statusO, outO := postRaw(t, promoteURL, body, owner)
242 statusM, outM := postRaw(t, promoteURL, body, member)
243 if statusO != http.StatusForbidden || statusM != http.StatusForbidden {
244 t.Fatalf("legacy stable promote = owner %d, member %d, want both 403", statusO, statusM)
245 }
246 if outO != outM {
247 t.Fatalf("legacy stable refusal not byte-identical:\nowner: %q\nmember: %q", outO, outM)
248 }
249 if !strings.Contains(outO, "stable needs a passkey — finish in the console") {
250 t.Fatalf("legacy stable refusal = %q, want today's exact sentence", outO)
251 }
252 channelAbsent(t, fx, "titogo", "stable")
253
254 // The new hotfix field changes nothing about it: rules are read before
255 // the flag ever is, and a pipeline-less app's stable rung is a rule.
256 statusH, outH := postRaw(t, promoteURL,
257 map[string]any{"version": "abc1234", "channel": "stable", "hotfix": true}, owner)
258 if statusH != http.StatusForbidden || outH != outO {
259 t.Fatalf("hotfixed legacy stable promote = %d %q, want the same 403 %q", statusH, outH, outO)
260 }
261 channelAbsent(t, fx, "titogo", "stable")
262
263 // And below stable the legacy ladder still promotes, untouched.
264 status, out := postJSON(t, promoteURL, map[string]any{"version": "abc1234", "channel": "edge"}, owner)
265 if status != http.StatusOK || str(t, out, "result") != "promoted" {
266 t.Fatalf("legacy edge promote = %d %v, want a plain 200 promotion", status, out)
267 }
268 if got := fx.channelVersion(t, "titogo", "edge"); got.Version != "abc1234" {
269 t.Fatalf("edge serves %q, want abc1234", got.Version)
270 }
271}
272
273// TestPolicyEditPinsSetAt: a policy proposal pins the pipeline it was read
274// against. A DIFFERENT write landing in between invalidates it — the channel
275// list it carries describes a world that no longer exists, and quietly
276// applying it would silently revert whatever landed meanwhile.
277func TestPolicyEditPinsSetAt(t *testing.T) {
278 fx := newConsoleFixture(t)
279 if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil {
280 t.Fatal(err)
281 }
282 setPipeline(t, fx, "widget",
283 release.PipelineChannel{Name: "edge"},
284 release.PipelineChannel{Name: "production", Bake: "24h",
285 Rules: release.ChannelRules{ChangeApprovals: 1}})
286 token := cliToken(t, fx, "paul@keymail.dev", "code-policy-pin")
287 pipelineURL := fx.ts.URL + "/api/cli/apps/bab/widget/pipeline"
288
289 // A guarded edit proposes instead of landing.
290 status, out := putJSON(t, pipelineURL, map[string]any{"channels": []map[string]any{
291 {"name": "edge"},
292 {"name": "production", "bake": "1h", "rules": map[string]any{"change_approvals": 1}},
293 }}, token)
294 if status != http.StatusAccepted {
295 t.Fatalf("guarded pipeline edit = %d %v, want 202", status, out)
296 }
297 id := str(t, out, "pending_id")
298 if id == "" || num(t, out, "needed") != 1 || num(t, out, "confirmed") != 0 {
299 t.Fatalf("policy proposal = %v, want a pending_id at 0/1", out)
300 }
301
302 // An UNGUARDED edit — production's own entry preserved exactly — lands
303 // straight away, and moves the pipeline underneath the proposal.
304 status, out = putJSON(t, pipelineURL, map[string]any{"channels": []map[string]any{
305 {"name": "edge", "bake": "30m"},
306 {"name": "production", "bake": "24h", "rules": map[string]any{"change_approvals": 1}},
307 }}, token)
308 if status != http.StatusOK {
309 t.Fatalf("unguarded pipeline edit = %d %v, want 200", status, out)
310 }
311
312 // Confirming the original to quorum: execution refuses.
313 c := newBrowser(t)
314 signUp(t, fx, c, "code-policy-confirm")
315 csrf := sessionToken(t, fx, c)
316 status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/pending/"+id+"/confirm"),
317 url.Values{"csrf": {csrf}})
318 if status != http.StatusSeeOther {
319 t.Fatalf("confirming a stale policy proposal = %d -> %q:\n%s", status, loc, body)
320 }
321 p, err := release.GetPipeline(context.Background(), fx.rel, fx.pub, release.OpsAccount, "widget")
322 if err != nil || p == nil {
323 t.Fatalf("pipeline after the refused execution = %v, %v", p, err)
324 }
325 if len(p.Channels) != 2 || p.Channels[0].Bake != "30m" || p.Channels[1].Bake != "24h" {
326 t.Fatalf("pipeline after the refused execution = %+v, want the unguarded edit intact", p.Channels)
327 }
328 // The change stays: a refused execution is retryable, not consumed.
329 left, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
330 if err != nil {
331 t.Fatal(err)
332 }
333 if len(left) != 1 || left[0].ID != id {
334 t.Fatalf("pending changes after a refused execution = %v, want the proposal still there", left)
335 }
336}
337
338// TestHotfixOnlyReachesPipelineChannels closes the hole review found: on a
339// PIPELINED app, `stable` is not one of its channels, so it carried no rules
340// — and a hotfixed promote would have skipped the order check and written the
341// stable pointer from a bearer token, a target that has been an unconditional
342// 403 at this door since it existed. The flag now applies only INSIDE the
343// app's declared order; outside it there is no order to bypass, only a
344// pipeline to escape, and the sanctioned escape is a direct-bucket promote
345// with the root key (spec §6).
346func TestHotfixOnlyReachesPipelineChannels(t *testing.T) {
347 fx := newConsoleFixture(t)
348 pipelinedApp(t, fx, "widget", "abc1234",
349 release.PipelineChannel{Name: "edge"},
350 release.PipelineChannel{Name: "beta"},
351 release.PipelineChannel{Name: "production"})
352 token := cliToken(t, fx, "paul@keymail.dev", "code-hotfix-scope")
353 promoteURL := fx.ts.URL + "/api/cli/apps/bab/widget/promote"
354
355 // The stable RUNG, which this pipeline does not name: the byte-identical
356 // legacy refusal, hotfix or no hotfix.
357 plain, plainBody := postRaw(t, promoteURL,
358 map[string]any{"version": "abc1234", "channel": "stable"}, token)
359 hot, hotBody := postRaw(t, promoteURL,
360 map[string]any{"version": "abc1234", "channel": "stable", "hotfix": true}, token)
361 if plain != http.StatusForbidden || hot != http.StatusForbidden {
362 t.Fatalf("stable on a pipelined app = plain %d, hotfixed %d, want both 403", plain, hot)
363 }
364 if plainBody != hotBody {
365 t.Fatalf("the hotfix flag changed the stable refusal:\nplain: %q\nhot: %q", plainBody, hotBody)
366 }
367 if !strings.Contains(hotBody, "stable needs a passkey — finish in the console") {
368 t.Fatalf("stable refusal = %q, want today's exact sentence", hotBody)
369 }
370 channelAbsent(t, fx, "widget", "stable")
371
372 // Any other channel the pipeline does not name: refused, and the refusal
373 // names the channels it does.
374 status, out := postJSON(t, promoteURL,
375 map[string]any{"version": "abc1234", "channel": "wip", "hotfix": true}, token)
376 if status != http.StatusConflict {
377 t.Fatalf("hotfixed promote onto an out-of-pipeline channel = %d %v, want 409", status, out)
378 }
379 if !strings.Contains(str(t, out, "error"), "not one of this app's channels") {
380 t.Fatalf("out-of-pipeline refusal = %v, want it to name the pipeline's channels", out["error"])
381 }
382 channelAbsent(t, fx, "widget", "wip")
383
384 // canary/* is outside every pipeline BY DESIGN (spec §2) and stays
385 // always-allowed — the fleet runs on canary channels.
386 status, out = postJSON(t, promoteURL,
387 map[string]any{"version": "abc1234", "channel": "canary/x"}, token)
388 if status != http.StatusOK || str(t, out, "result") != "promoted" {
389 t.Fatalf("canary promote on a pipelined app = %d %v, want it still allowed", status, out)
390 }
391
392 // And INSIDE the pipeline the flag does what it is for: abc1234 sits on
393 // edge, production is entered from beta, and the flag carries it there.
394 status, out = postJSON(t, promoteURL,
395 map[string]any{"version": "abc1234", "channel": "production", "hotfix": true}, token)
396 if status != http.StatusOK || str(t, out, "result") != "promoted" {
397 t.Fatalf("hotfixed in-pipeline promote = %d %v, want 200 promoted", status, out)
398 }
399 if got := fx.channelVersion(t, "widget", "production"); got.Version != "abc1234" {
400 t.Fatalf("production serves %q, want abc1234", got.Version)
401 }
402}
403
404// TestRollbackMeetsChannelRules: a rollback is a pointer write, and what it
405// puts in front of every user of the app is a build the channel's rules exist
406// to govern. Review found a guarded channel could be rolled back by any
407// member or bearer token with no gate at all.
408func TestRollbackMeetsChannelRules(t *testing.T) {
409 t.Run("api", func(t *testing.T) {
410 fx := newConsoleFixture(t)
411 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil {
412 t.Fatal(err)
413 }
414 if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil {
415 t.Fatal(err)
416 }
417 shipVersion(t, fx, "widget", "v1")
418 shipVersion(t, fx, "widget", "v2")
419 seedChannel(t, fx, "widget", "production", "v1")
420 seedChannel(t, fx, "widget", "production", "v2")
421 setPipeline(t, fx, "widget",
422 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{Passkey: true}})
423 token := cliToken(t, fx, "paul@keymail.dev", "code-rollback-rules")
424 rollbackURL := fx.ts.URL + "/api/cli/apps/bab/widget/rollback"
425
426 status, out := postJSON(t, rollbackURL, map[string]any{"channel": "production"}, token)
427 if status != http.StatusForbidden {
428 t.Fatalf("rollback of a passkey channel = %d %v, want 403", status, out)
429 }
430 if !strings.Contains(str(t, out, "error"), "production needs a passkey") {
431 t.Fatalf("rollback refusal = %v, want it to name the channel and the passkey", out["error"])
432 }
433 if got := fx.channelVersion(t, "widget", "production"); got.Version != "v2" {
434 t.Fatalf("production serves %q after a refused rollback, want v2", got.Version)
435 }
436
437 // Approvals instead: the door proposes, pinned to the version the
438 // rollback would land on.
439 setPipeline(t, fx, "widget",
440 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}})
441 status, out = postJSON(t, rollbackURL, map[string]any{"channel": "production"}, token)
442 if status != http.StatusAccepted {
443 t.Fatalf("rollback of an approvals channel = %d %v, want 202", status, out)
444 }
445 if str(t, out, "result") != "proposed" || num(t, out, "needed") != 2 {
446 t.Fatalf("rollback proposal = %v, want a proposal needing 2", out)
447 }
448 if got := fx.channelVersion(t, "widget", "production"); got.Version != "v2" {
449 t.Fatalf("production serves %q after a proposal, want v2 — nothing moved", got.Version)
450 }
451 pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
452 if err != nil || len(pending) != 1 {
453 t.Fatalf("pending = %v, %v, want one", pending, err)
454 }
455 if !strings.Contains(pendingSummary(pending[0]), "v1") {
456 t.Fatalf("summary %q does not pin the version the rollback lands on", pendingSummary(pending[0]))
457 }
458 })
459
460 t.Run("legacy apps unchanged", func(t *testing.T) {
461 fx := newConsoleFixture(t)
462 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
463 t.Fatal(err)
464 }
465 shipVersion(t, fx, "titogo", "v1")
466 shipVersion(t, fx, "titogo", "v2")
467 seedChannel(t, fx, "titogo", "beta", "v1")
468 seedChannel(t, fx, "titogo", "beta", "v2")
469 token := cliToken(t, fx, "paul@keymail.dev", "code-rollback-legacy")
470
471 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/rollback",
472 map[string]any{"channel": "beta"}, token)
473 if status != http.StatusOK {
474 t.Fatalf("legacy rollback = %d %v, want 200", status, out)
475 }
476 if got := fx.channelVersion(t, "titogo", "beta"); got.Version != "v1" {
477 t.Fatalf("beta serves %q after a legacy rollback, want v1", got.Version)
478 }
479 })
480
481 t.Run("browser proposes and quorum executes", func(t *testing.T) {
482 fx, c, csrf := memberFixture(t) // paul owns, kim is a member
483 if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil {
484 t.Fatal(err)
485 }
486 shipVersion(t, fx, "widget", "v1")
487 shipVersion(t, fx, "widget", "v2")
488 seedChannel(t, fx, "widget", "production", "v1")
489 seedChannel(t, fx, "widget", "production", "v2")
490 setPipeline(t, fx, "widget",
491 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}})
492
493 status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/rollback"),
494 url.Values{"csrf": {csrf}, "channel": {"production"}, "confirmed": {"1"}})
495 if status != http.StatusSeeOther {
496 t.Fatalf("browser rollback proposal = %d -> %q:\n%s", status, loc, body)
497 }
498 if got := fx.channelVersion(t, "widget", "production"); got.Version != "v2" {
499 t.Fatalf("production serves %q after a proposal, want v2", got.Version)
500 }
501 pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
502 if err != nil || len(pending) != 1 || len(pending[0].Confirms) != 1 {
503 t.Fatalf("pending = %v, %v, want one at 1 of 2", pending, err)
504 }
505
506 kim := newBrowser(t)
507 fx.setUser("kim@keymail.dev")
508 signUp(t, fx, kim, "code-kim-rollback")
509 kimCSRF := sessionToken(t, fx, kim)
510 status, loc, body = postGated(t, fx, kim,
511 fx.u("/apps/widget/pending/"+pending[0].ID+"/confirm"), url.Values{"csrf": {kimCSRF}})
512 if status != http.StatusSeeOther {
513 t.Fatalf("confirmation = %d -> %q:\n%s", status, loc, body)
514 }
515 if got := fx.channelVersion(t, "widget", "production"); got.Version != "v1" {
516 t.Fatalf("production serves %q after quorum, want the rolled-back v1", got.Version)
517 }
518 })
519}
520
521// TestExecutePendingRefusesItsOwnPreconditions: executePending is billed as
522// the one place a gated write happens, and Task 9 adds a caller. Neither an
523// under-confirmed change nor one belonging to another app may write, and
524// neither may panic — a bug in a caller has to refuse, not take the console
525// down or land an unapproved promotion.
526func TestExecutePendingRefusesItsOwnPreconditions(t *testing.T) {
527 fx := newConsoleFixture(t)
528 pipelinedApp(t, fx, "widget", "abc1234",
529 release.PipelineChannel{Name: "edge"},
530 release.PipelineChannel{Name: "production"})
531 acct, err := fx.st.AccountBySqid(fx.sqid)
532 if err != nil || acct == nil {
533 t.Fatalf("account: %v, %v", acct, err)
534 }
535 payload := []byte(`{"version":"abc1234","channel":"production"}`)
536 ctx := context.Background()
537
538 short, err := fx.st.ProposeChange(OpsAccountID, "widget", "promote", payload, 2,
539 "paul@keymail.dev", true, time.Now())
540 if err != nil {
541 t.Fatal(err)
542 }
543 if msg, err := fx.srv.executePending(ctx, acct, "widget", "paul@keymail.dev", short); err == nil {
544 t.Fatalf("executing a 1-of-2 change succeeded (%q), want a refusal", msg)
545 }
546 channelAbsent(t, fx, "widget", "production")
547
548 elsewhere, err := fx.st.ProposeChange(OpsAccountID, "kass", "promote", payload, 1,
549 "paul@keymail.dev", true, time.Now())
550 if err != nil {
551 t.Fatal(err)
552 }
553 if msg, err := fx.srv.executePending(ctx, acct, "widget", "paul@keymail.dev", elsewhere); err == nil {
554 t.Fatalf("executing another app's change succeeded (%q), want a refusal", msg)
555 }
556 channelAbsent(t, fx, "widget", "production")
557}
558
559// TestFastTrackProposesWhenGuarded: change_approvals covers overriding a
560// bake as well as editing a channel's entry (spec §2), and the waiver it
561// gates is pinned to the build it was proposed for.
562func TestFastTrackProposesWhenGuarded(t *testing.T) {
563 fx, c, csrf := memberFixture(t) // paul owns, kim is a member
564 pipelinedApp(t, fx, "widget", "abc1234",
565 release.PipelineChannel{Name: "edge", Bake: "24h",
566 Rules: release.ChannelRules{ChangeApprovals: 2}})
567
568 status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/fasttrack"),
569 url.Values{"csrf": {csrf}, "channel": {"edge"}, "confirmed": {"1"}})
570 if status != http.StatusSeeOther {
571 t.Fatalf("owner fast-track proposal = %d -> %q:\n%s", status, loc, body)
572 }
573 if p := fx.channelVersion(t, "widget", "edge"); p.FastTrack {
574 t.Fatal("the waiver landed on one owner's say-so despite change_approvals: 2")
575 }
576 pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
577 if err != nil {
578 t.Fatal(err)
579 }
580 if len(pending) != 1 || pending[0].Kind != "fasttrack" || pending[0].Quorum != 2 {
581 t.Fatalf("pending = %+v, want one fasttrack needing 2", pending)
582 }
583 if !strings.Contains(pendingSummary(pending[0]), "abc1234") {
584 t.Fatalf("summary %q does not name the pinned build", pendingSummary(pending[0]))
585 }
586
587 kim := newBrowser(t)
588 fx.setUser("kim@keymail.dev")
589 signUp(t, fx, kim, "code-kim-fasttrack")
590 kimCSRF := sessionToken(t, fx, kim)
591 status, loc, body = postGated(t, fx, kim,
592 fx.u("/apps/widget/pending/"+pending[0].ID+"/confirm"), url.Values{"csrf": {kimCSRF}})
593 if status != http.StatusSeeOther {
594 t.Fatalf("second confirmation = %d -> %q:\n%s", status, loc, body)
595 }
596 p := fx.channelVersion(t, "widget", "edge")
597 if !p.FastTrack {
598 t.Fatal("edge does not wear the waiver after quorum")
599 }
600 if len(p.Audit) == 0 || !p.Audit[0].FastTrack {
601 t.Fatalf("pointer audit = %+v, want the waiver recorded", p.Audit)
602 }
603}
604
605// TestPendingDoorsAreNotAnOracle: a pending change is reachable only by a
606// member of the account that owns it, only under its own app, and only for
607// rejection by its proposer or an owner. Every other shape is the same 404 an
608// id that never existed gets — ConfirmChange itself takes no account id, so
609// these doors ARE the authorization boundary.
610func TestPendingDoorsAreNotAnOracle(t *testing.T) {
611 fx, c, csrf := memberFixture(t) // paul owns bab, kim is a member of it
612 pipelinedApp(t, fx, "widget", "abc1234",
613 release.PipelineChannel{Name: "edge"},
614 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}})
615 if _, err := fx.st.CreateAppIn(OpsAccountID, "other", "paul@keymail.dev", time.Now()); err != nil {
616 t.Fatal(err)
617 }
618 // A second account entirely, with a change of its own.
619 if err := fx.st.CreateAccount(2, "bac", "widgets", "bob@keymail.dev", time.Now()); err != nil {
620 t.Fatal(err)
621 }
622 theirs, err := fx.st.ProposeChange(2, "widget", "promote",
623 []byte(`{"version":"abc1234","channel":"production"}`), 2, "bob@keymail.dev", false, time.Now())
624 if err != nil {
625 t.Fatal(err)
626 }
627
628 status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/promote"),
629 url.Values{"csrf": {csrf}, "channel": {"production"}, "version": {"abc1234"}, "confirmed": {"1"}})
630 if status != http.StatusSeeOther {
631 t.Fatalf("proposal = %d -> %q:\n%s", status, loc, body)
632 }
633 pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
634 if err != nil || len(pending) != 1 {
635 t.Fatalf("pending = %v, %v, want exactly one", pending, err)
636 }
637 id := pending[0].ID
638
639 // An id that never existed sets the baseline refusal.
640 _, _, unknown := postForm(t, c, fx.u("/apps/widget/pending/deadbeef/confirm"), url.Values{"csrf": {csrf}})
641 for name, u := range map[string]string{
642 "another account's change": fx.u("/apps/widget/pending/" + theirs.ID + "/confirm"),
643 "the wrong app": fx.u("/apps/other/pending/" + id + "/confirm"),
644 } {
645 status, _, got := postForm(t, c, u, url.Values{"csrf": {csrf}})
646 if status != http.StatusNotFound || got != unknown {
647 t.Fatalf("%s = %d, body %q, want the same 404 an unknown id gets", name, status, got)
648 }
649 }
650 // None of that recorded a confirmation anywhere.
651 if pc, _ := fx.st.PendingChange(theirs.ID, time.Now()); pc == nil || len(pc.Confirms) != 0 {
652 t.Fatalf("the other account's change = %+v, want it untouched at zero confirmations", pc)
653 }
654
655 // A member who is neither proposer nor owner cannot withdraw it.
656 kim := newBrowser(t)
657 fx.setUser("kim@keymail.dev")
658 signUp(t, fx, kim, "code-kim-reject")
659 kimCSRF := sessionToken(t, fx, kim)
660 status, _, got := postForm(t, kim, fx.u("/apps/widget/pending/"+id+"/reject"), url.Values{"csrf": {kimCSRF}})
661 if status != http.StatusNotFound || got != unknown {
662 t.Fatalf("a bystander rejecting = %d, body %q, want the unknown-id 404", status, got)
663 }
664 if pc, _ := fx.st.PendingChange(id, time.Now()); pc == nil {
665 t.Fatal("a bystander's rejection deleted the change")
666 }
667
668 // The proposer withdraws it — no passkey needed to REFUSE authority.
669 status, _, body = postForm(t, c, fx.u("/apps/widget/pending/"+id+"/reject"), url.Values{"csrf": {csrf}})
670 if status != http.StatusSeeOther {
671 t.Fatalf("proposer rejecting = %d:\n%s", status, body)
672 }
673 if pc, _ := fx.st.PendingChange(id, time.Now()); pc != nil {
674 t.Fatalf("the change survived its proposer's rejection: %+v", pc)
675 }
676 channelAbsent(t, fx, "widget", "production")
677}
678
679// TestPromoteProposalPinsObservedHead is spec §4's other pin: "a head that
680// moved underneath a proposal invalidates it". A promote proposal records the
681// target channel's head at propose time; execution refuses when the head has
682// moved since — the pointer stays where whoever moved it put it, and the
683// change stays listed (retryable-in-principle, consistent with the policy
684// pin), never silently landed over a world nobody approved.
685func TestPromoteProposalPinsObservedHead(t *testing.T) {
686 fx, c, csrf := memberFixture(t) // paul owns, kim is a member
687 pipelinedApp(t, fx, "widget", "v2",
688 release.PipelineChannel{Name: "edge"},
689 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}})
690 shipVersion(t, fx, "widget", "v1")
691 seedChannel(t, fx, "widget", "production", "v1") // head A at propose time
692
693 status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/promote"),
694 url.Values{"csrf": {csrf}, "channel": {"production"}, "version": {"v2"}, "confirmed": {"1"}})
695 if status != http.StatusSeeOther {
696 t.Fatalf("proposal = %d -> %q:\n%s", status, loc, body)
697 }
698 pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
699 if err != nil || len(pending) != 1 {
700 t.Fatalf("pending = %v, %v, want exactly one", pending, err)
701 }
702 pc := pending[0]
703 if !strings.Contains(string(pc.Payload), `"observed_head":"v1"`) {
704 t.Fatalf("payload = %s, want the observed head v1 pinned", pc.Payload)
705 }
706
707 // The head moves underneath the proposal.
708 shipVersion(t, fx, "widget", "v3")
709 seedChannel(t, fx, "widget", "production", "v3")
710
711 // Confirming to quorum: execution refuses, nothing is written, and the
712 // change stays listed — exactly the policy pin's shape.
713 kim := newBrowser(t)
714 fx.setUser("kim@keymail.dev")
715 signUp(t, fx, kim, "code-kim-headpin")
716 kimCSRF := sessionToken(t, fx, kim)
717 before := serial(t, fx)
718 status, loc, body = postGated(t, fx, kim,
719 fx.u("/apps/widget/pending/"+pc.ID+"/confirm"), url.Values{"csrf": {kimCSRF}})
720 if status != http.StatusSeeOther {
721 t.Fatalf("confirming a moved-head proposal = %d -> %q:\n%s", status, loc, body)
722 }
723 if got := fx.channelVersion(t, "widget", "production"); got.Version != "v3" {
724 t.Fatalf("production serves %q after the refused execution, want v3 untouched", got.Version)
725 }
726 if got := serial(t, fx); got != before {
727 t.Fatalf("serial = %d after a refused execution, want %d — nothing was written for a box to read", got, before)
728 }
729 left, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
730 if err != nil || len(left) != 1 || left[0].ID != pc.ID {
731 t.Fatalf("pending after a refused execution = %v, %v, want the proposal still there", left, err)
732 }
733}
734
735// TestPromoteAPIPinsObservedHead: the bearer-token doors pin the head too —
736// the API promote door records the target channel's current version, and
737// omits the field entirely for a channel with no pointer yet.
738func TestPromoteAPIPinsObservedHead(t *testing.T) {
739 fx := newConsoleFixture(t)
740 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil {
741 t.Fatal(err)
742 }
743 pipelinedApp(t, fx, "widget", "v2",
744 release.PipelineChannel{Name: "edge"},
745 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}})
746 token := cliToken(t, fx, "paul@keymail.dev", "code-head-pin-api")
747 promoteURL := fx.ts.URL + "/api/cli/apps/bab/widget/promote"
748
749 // production has NO pointer yet: the proposal carries no pin.
750 status, out := postJSON(t, promoteURL, map[string]any{"version": "v2", "channel": "production"}, token)
751 if status != http.StatusAccepted {
752 t.Fatalf("promote proposal = %d %v, want 202", status, out)
753 }
754 pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
755 if err != nil || len(pending) != 1 {
756 t.Fatalf("pending = %v, %v, want one", pending, err)
757 }
758 if strings.Contains(string(pending[0].Payload), "observed_head") {
759 t.Fatalf("payload = %s, want NO observed_head for a channel with no pointer", pending[0].Payload)
760 }
761 if err := fx.st.DeleteChange(pending[0].ID); err != nil {
762 t.Fatal(err)
763 }
764
765 // With a head in place, the proposal pins it.
766 shipVersion(t, fx, "widget", "v1")
767 seedChannel(t, fx, "widget", "production", "v1")
768 status, out = postJSON(t, promoteURL, map[string]any{"version": "v2", "channel": "production"}, token)
769 if status != http.StatusAccepted {
770 t.Fatalf("promote proposal = %d %v, want 202", status, out)
771 }
772 pending, err = fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
773 if err != nil || len(pending) != 1 {
774 t.Fatalf("pending = %v, %v, want one", pending, err)
775 }
776 if !strings.Contains(string(pending[0].Payload), `"observed_head":"v1"`) {
777 t.Fatalf("payload = %s, want the observed head v1 pinned", pending[0].Payload)
778 }
779}
780
781// TestRollbackProposalPinsObservedHead: the rollback doors pin the head they
782// read — the version the rollback is FROM — so a channel that moved again
783// while the proposal waited invalidates it at execution.
784func TestRollbackProposalPinsObservedHead(t *testing.T) {
785 fx := newConsoleFixture(t)
786 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil {
787 t.Fatal(err)
788 }
789 if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil {
790 t.Fatal(err)
791 }
792 shipVersion(t, fx, "widget", "v1")
793 shipVersion(t, fx, "widget", "v2")
794 seedChannel(t, fx, "widget", "production", "v1")
795 seedChannel(t, fx, "widget", "production", "v2")
796 setPipeline(t, fx, "widget",
797 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}})
798 token := cliToken(t, fx, "paul@keymail.dev", "code-head-pin-rollback")
799
800 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/rollback",
801 map[string]any{"channel": "production"}, token)
802 if status != http.StatusAccepted {
803 t.Fatalf("rollback proposal = %d %v, want 202", status, out)
804 }
805 pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
806 if err != nil || len(pending) != 1 {
807 t.Fatalf("pending = %v, %v, want one", pending, err)
808 }
809 if !strings.Contains(string(pending[0].Payload), `"observed_head":"v2"`) {
810 t.Fatalf("payload = %s, want the head being rolled back from (v2) pinned", pending[0].Payload)
811 }
812}
813
814// TestPromoteOldRowWithoutObservedHeadExecutes: a row written before the
815// observed_head field existed carries no pin and keeps its old behaviour —
816// wire-shape discipline for rows already sitting in production consoles.
817func TestPromoteOldRowWithoutObservedHeadExecutes(t *testing.T) {
818 fx := newConsoleFixture(t)
819 pipelinedApp(t, fx, "widget", "v2",
820 release.PipelineChannel{Name: "edge"},
821 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 1}})
822 shipVersion(t, fx, "widget", "v1")
823 seedChannel(t, fx, "widget", "production", "v1")
824 acct, err := fx.st.AccountBySqid(fx.sqid)
825 if err != nil || acct == nil {
826 t.Fatalf("account: %v, %v", acct, err)
827 }
828 // The pre-change payload shape, verbatim: no observed_head.
829 pc, err := fx.st.ProposeChange(OpsAccountID, "widget", "promote",
830 []byte(`{"version":"v2","channel":"production"}`), 1, "paul@keymail.dev", true, time.Now())
831 if err != nil {
832 t.Fatal(err)
833 }
834 if msg, err := fx.srv.executePending(context.Background(), acct, "widget", "paul@keymail.dev", pc); err != nil {
835 t.Fatalf("executing an old row without the pin = %v (%q), want it to land as before", err, msg)
836 }
837 if got := fx.channelVersion(t, "widget", "production"); got.Version != "v2" {
838 t.Fatalf("production serves %q, want v2 — old rows keep current behaviour", got.Version)
839 }
840}
841
842// TestRollbackExecutionAuditSaysRolledBack: the card says "roll back", so the
843// execution's audit row must too — "promoted v1 to production" for a rollback
844// is illegible to whoever reads the trail later (same Task 8 argument that
845// fixed the summary).
846func TestRollbackExecutionAuditSaysRolledBack(t *testing.T) {
847 fx, c, csrf := memberFixture(t) // paul owns, kim is a member
848 if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil {
849 t.Fatal(err)
850 }
851 shipVersion(t, fx, "widget", "v1")
852 shipVersion(t, fx, "widget", "v2")
853 seedChannel(t, fx, "widget", "production", "v1")
854 seedChannel(t, fx, "widget", "production", "v2")
855 setPipeline(t, fx, "widget",
856 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}})
857
858 status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/rollback"),
859 url.Values{"csrf": {csrf}, "channel": {"production"}, "confirmed": {"1"}})
860 if status != http.StatusSeeOther {
861 t.Fatalf("rollback proposal = %d -> %q:\n%s", status, loc, body)
862 }
863 pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
864 if err != nil || len(pending) != 1 {
865 t.Fatalf("pending = %v, %v, want one", pending, err)
866 }
867 kim := newBrowser(t)
868 fx.setUser("kim@keymail.dev")
869 signUp(t, fx, kim, "code-kim-audit")
870 kimCSRF := sessionToken(t, fx, kim)
871 status, loc, body = postGated(t, fx, kim,
872 fx.u("/apps/widget/pending/"+pending[0].ID+"/confirm"), url.Values{"csrf": {kimCSRF}})
873 if status != http.StatusSeeOther {
874 t.Fatalf("confirmation = %d -> %q:\n%s", status, loc, body)
875 }
876 if got := fx.channelVersion(t, "widget", "production"); got.Version != "v1" {
877 t.Fatalf("production serves %q after quorum, want the rolled-back v1", got.Version)
878 }
879 events, err := fx.st.Events(OpsAccountID, "widget", 50)
880 if err != nil {
881 t.Fatal(err)
882 }
883 var found bool
884 for _, ev := range events {
885 if strings.Contains(ev.Detail, "promoted v1 to production") {
886 t.Fatalf("audit row %q says promoted for a rollback proposal", ev.Detail)
887 }
888 if ev.Action == "rollback" && strings.Contains(ev.Detail, "rolled back production to v1") &&
889 strings.Contains(ev.Detail, "proposed by") {
890 found = true
891 }
892 }
893 if !found {
894 t.Fatalf("no rollback-voiced audit row in %+v", events)
895 }
896}
897
898// TestHotfixBypassesOrderNotRules is spec §5's exact sentence, twice over:
899// the flag never buys past a channel's rules, and it does bypass the order
900// on a channel that carries none.
901func TestHotfixBypassesOrderNotRules(t *testing.T) {
902 fx := newConsoleFixture(t)
903 pipelinedApp(t, fx, "widget", "abc1234",
904 release.PipelineChannel{Name: "edge"},
905 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{Passkey: true}})
906 token := cliToken(t, fx, "paul@keymail.dev", "code-hotfix-rules")
907 promoteURL := fx.ts.URL + "/api/cli/apps/bab/widget/promote"
908
909 status, out := postJSON(t, promoteURL,
910 map[string]any{"version": "abc1234", "channel": "production", "hotfix": true}, token)
911 if status != http.StatusForbidden {
912 t.Fatalf("hotfixed promote into a passkey channel = %d %v, want the 403 passkey refusal", status, out)
913 }
914 if !strings.Contains(str(t, out, "error"), "passkey") {
915 t.Fatalf("hotfixed refusal = %v, want it to name the passkey", out["error"])
916 }
917 channelAbsent(t, fx, "widget", "production")
918
919 // An out-of-order target with no rules at all: refused without the flag,
920 // allowed with it.
921 setPipeline(t, fx, "widget",
922 release.PipelineChannel{Name: "edge"},
923 release.PipelineChannel{Name: "beta"},
924 release.PipelineChannel{Name: "production"})
925 status, out = postJSON(t, promoteURL,
926 map[string]any{"version": "abc1234", "channel": "production"}, token)
927 if status != http.StatusConflict {
928 t.Fatalf("out-of-order promote = %d %v, want 409", status, out)
929 }
930 if !strings.Contains(str(t, out, "error"), "beta") {
931 t.Fatalf("order refusal = %v, want it to name the predecessor", out["error"])
932 }
933 channelAbsent(t, fx, "widget", "production")
934
935 status, out = postJSON(t, promoteURL,
936 map[string]any{"version": "abc1234", "channel": "production", "hotfix": true}, token)
937 if status != http.StatusOK || str(t, out, "result") != "promoted" {
938 t.Fatalf("hotfixed out-of-order promote = %d %v, want 200 promoted", status, out)
939 }
940 if got := fx.channelVersion(t, "widget", "production"); got.Version != "abc1234" {
941 t.Fatalf("production serves %q, want abc1234", got.Version)
942 }
943}
944
945// TestExecutionMeetsTheCURRENTQuorum: a proposal carries the quorum that was
946// in force when it was made, and a week can pass before it is confirmed. If
947// the channel's rule was TIGHTENED in between — 2 approvals to 3 — executing
948// at the stored 2 would let a stale proposal be the way past the new rule.
949// Execution therefore re-reads the pipeline and requires max(stored, current).
950// The refusal is an error, not a consumption: the change stays listed and
951// stays retryable, so the third member can still meet it.
952func TestExecutionMeetsTheCURRENTQuorum(t *testing.T) {
953 fx, c, csrf := memberFixture(t) // paul owns, kim is a member
954 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil {
955 t.Fatal(err)
956 }
957 pipelinedApp(t, fx, "widget", "abc1234",
958 release.PipelineChannel{Name: "edge"},
959 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}})
960
961 status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/promote"),
962 url.Values{"csrf": {csrf}, "channel": {"production"}, "version": {"abc1234"}, "confirmed": {"1"}})
963 if status != http.StatusSeeOther {
964 t.Fatalf("owner proposal = %d -> %q:\n%s", status, loc, body)
965 }
966 pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
967 if err != nil {
968 t.Fatal(err)
969 }
970 if len(pending) != 1 || pending[0].Quorum != 2 {
971 t.Fatalf("pending = %+v, want one promote needing 2", pending)
972 }
973 pc := pending[0]
974
975 // The rule is raised while the proposal waits.
976 setPipeline(t, fx, "widget",
977 release.PipelineChannel{Name: "edge"},
978 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 3}})
979
980 kim := newBrowser(t)
981 fx.setUser("kim@keymail.dev")
982 signUp(t, fx, kim, "code-kim-tightened")
983 kimCSRF := sessionToken(t, fx, kim)
984 before := serial(t, fx)
985 status, loc, body = postGated(t, fx, kim,
986 fx.u("/apps/widget/pending/"+pc.ID+"/confirm"), url.Values{"csrf": {kimCSRF}})
987 if status != http.StatusSeeOther {
988 t.Fatalf("second confirmation = %d -> %q:\n%s", status, loc, body)
989 }
990 channelAbsent(t, fx, "widget", "production")
991 if got := serial(t, fx); got != before {
992 t.Fatalf("serial = %d, want it unmoved at %d — nothing may be written under a quorum that is no longer enough", got, before)
993 }
994 left, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
995 if err != nil {
996 t.Fatal(err)
997 }
998 if len(left) != 1 || left[0].ID != pc.ID {
999 t.Fatalf("pending after the refusal = %+v, want the change still listed and retryable", left)
1000 }
1001 if len(left[0].Confirms) != 2 {
1002 t.Fatalf("confirmations = %v, want both kept — a refusal must not discard them", left[0].Confirms)
1003 }
1004
1005 // The third member meets the new rule, and THAT lands it.
1006 alice := newBrowser(t)
1007 fx.setUser("alice@keymail.dev")
1008 signUp(t, fx, alice, "code-alice-tightened")
1009 aliceCSRF := sessionToken(t, fx, alice)
1010 status, loc, body = postGated(t, fx, alice,
1011 fx.u("/apps/widget/pending/"+pc.ID+"/confirm"), url.Values{"csrf": {aliceCSRF}})
1012 if status != http.StatusSeeOther {
1013 t.Fatalf("third confirmation = %d -> %q:\n%s", status, loc, body)
1014 }
1015 if got := fx.channelVersion(t, "widget", "production"); got.Version != "abc1234" {
1016 t.Fatalf("production serves %q after the raised quorum was met, want abc1234", got.Version)
1017 }
1018}
1019
1020// TestPendingTalliesShowWhatExecutionRequires: the card, the CLI list and the
1021// confirm flash must not promise a quorum execution will refuse. With the
1022// rule raised from 2 to 3 underneath a live proposal, every surface has to
1023// read 2 of 3 — a change sitting at "2 of 2" beside a Confirm button that
1024// refuses every time it is pressed is worse than no card at all (PR #258
1025// review, I3). And a confirmation from a departed member has to stop counting
1026// on those same surfaces, for the same reason.
1027func TestPendingTalliesShowWhatExecutionRequires(t *testing.T) {
1028 fx, c, csrf := memberFixture(t) // paul owns, kim is a member
1029 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil {
1030 t.Fatal(err)
1031 }
1032 pipelinedApp(t, fx, "widget", "abc1234",
1033 release.PipelineChannel{Name: "edge"},
1034 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}})
1035
1036 if status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/promote"),
1037 url.Values{"csrf": {csrf}, "channel": {"production"}, "version": {"abc1234"}, "confirmed": {"1"}}); status != http.StatusSeeOther {
1038 t.Fatalf("owner proposal = %d -> %q:\n%s", status, loc, body)
1039 }
1040 // Before the tightening the card reads 1/2 — pending.html's own format,
1041 // asserted here so the checks after the tightening are known to be
1042 // looking at the card and not at some flash that happens to say "of".
1043 _, _, body := get(t, c, fx.u("/apps/widget"))
1044 if !strings.Contains(body, "1/2 confirmed") {
1045 t.Fatalf("card does not show 1/2 confirmed before the tightening:\n%s", body)
1046 }
1047
1048 setPipeline(t, fx, "widget",
1049 release.PipelineChannel{Name: "edge"},
1050 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 3}})
1051
1052 // The card.
1053 _, _, body = get(t, c, fx.u("/apps/widget"))
1054 if !strings.Contains(body, "1/3 confirmed") {
1055 t.Errorf("card shows the stored quorum, not the one execution now requires:\n%s", body)
1056 }
1057 if strings.Contains(body, "1/2 confirmed") {
1058 t.Errorf("card still shows the superseded 1/2:\n%s", body)
1059 }
1060 // The CLI list.
1061 token := cliToken(t, fx, "paul@keymail.dev", "code-tally-list")
1062 status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pending", token)
1063 if status != http.StatusOK {
1064 t.Fatalf("pending list = %d %v", status, out)
1065 }
1066 list, _ := out["pending"].([]any)
1067 if len(list) != 1 {
1068 t.Fatalf("pending list = %v, want one row", out["pending"])
1069 }
1070 row, _ := list[0].(map[string]any)
1071 if num(t, row, "needed") != 3 || num(t, row, "confirmed") != 1 {
1072 t.Errorf("CLI row = %v, want 1 of 3", row)
1073 }
1074
1075 // The confirm flash, and the audit row behind it: kim's confirmation
1076 // takes it to 2 of 3, not "2 of 2, executed".
1077 pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
1078 if err != nil {
1079 t.Fatal(err)
1080 }
1081 kim := newBrowser(t)
1082 fx.setUser("kim@keymail.dev")
1083 signUp(t, fx, kim, "code-kim-tally")
1084 kimCSRF := sessionToken(t, fx, kim)
1085 if status, loc, body := postGated(t, fx, kim,
1086 fx.u("/apps/widget/pending/"+pending[0].ID+"/confirm"), url.Values{"csrf": {kimCSRF}}); status != http.StatusSeeOther {
1087 t.Fatalf("second confirmation = %d -> %q:\n%s", status, loc, body)
1088 }
1089 _, _, kimBody := get(t, kim, fx.u("/apps/widget"))
1090 if !strings.Contains(kimBody, "2/3 confirmed") {
1091 t.Errorf("after the second confirmation the card does not read 2/3:\n%s", kimBody)
1092 }
1093 events, err := fx.st.Events(OpsAccountID, "widget", 10)
1094 if err != nil {
1095 t.Fatal(err)
1096 }
1097 found := false
1098 for _, e := range events {
1099 if e.Action == "confirmed" && strings.Contains(e.Detail, "2 of 3 confirmations") {
1100 found = true
1101 }
1102 }
1103 if !found {
1104 t.Errorf("no audit row recording 2 of 3 confirmations: %+v", events)
1105 }
1106
1107 // And a departed member's row stops counting on the surfaces too.
1108 if err := fx.st.RemoveMember(OpsAccountID, "kim@keymail.dev"); err != nil {
1109 t.Fatal(err)
1110 }
1111 _, _, body = get(t, c, fx.u("/apps/widget"))
1112 if !strings.Contains(body, "1/3 confirmed") {
1113 t.Errorf("card counts a departed member's confirmation:\n%s", body)
1114 }
1115}
1116
1117// TestLoosenedRuleDoesNotLowerAStoredQuorum: the ratchet only goes one way.
1118// A proposal made at 3 approvals is not executable at 2 because somebody
1119// lowered the rule afterwards — the confirmers agreed to a change that needed
1120// three of them, and lowering the rule is not their confirmation.
1121func TestLoosenedRuleDoesNotLowerAStoredQuorum(t *testing.T) {
1122 fx, c, csrf := memberFixture(t) // paul owns, kim is a member
1123 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil {
1124 t.Fatal(err)
1125 }
1126 pipelinedApp(t, fx, "widget", "abc1234",
1127 release.PipelineChannel{Name: "edge"},
1128 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 3}})
1129
1130 if status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/promote"),
1131 url.Values{"csrf": {csrf}, "channel": {"production"}, "version": {"abc1234"}, "confirmed": {"1"}}); status != http.StatusSeeOther {
1132 t.Fatalf("owner proposal = %d -> %q:\n%s", status, loc, body)
1133 }
1134 pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
1135 if err != nil {
1136 t.Fatal(err)
1137 }
1138 if len(pending) != 1 || pending[0].Quorum != 3 {
1139 t.Fatalf("pending = %+v, want one promote needing 3", pending)
1140 }
1141 pc := pending[0]
1142
1143 setPipeline(t, fx, "widget",
1144 release.PipelineChannel{Name: "edge"},
1145 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}})
1146
1147 kim := newBrowser(t)
1148 fx.setUser("kim@keymail.dev")
1149 signUp(t, fx, kim, "code-kim-loosened")
1150 kimCSRF := sessionToken(t, fx, kim)
1151 if status, loc, body := postGated(t, fx, kim,
1152 fx.u("/apps/widget/pending/"+pc.ID+"/confirm"), url.Values{"csrf": {kimCSRF}}); status != http.StatusSeeOther {
1153 t.Fatalf("second confirmation = %d -> %q:\n%s", status, loc, body)
1154 }
1155 channelAbsent(t, fx, "widget", "production")
1156 left, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
1157 if err != nil {
1158 t.Fatal(err)
1159 }
1160 if len(left) != 1 {
1161 t.Fatalf("pending after the loosened rule = %+v, want the change still waiting on its own quorum of 3", left)
1162 }
1163}
1164
1165// TestDepartedMemberConfirmDoesNotCount: a quorum is N distinct MEMBERS. An
1166// address that has since left the account is not one, so its row must not
1167// carry a change over the line — otherwise removing somebody from an account
1168// would leave their standing approvals working on their way out. The row is
1169// kept (the trail of who confirmed what stays true); it simply stops counting.
1170func TestDepartedMemberConfirmDoesNotCount(t *testing.T) {
1171 fx, c, csrf := memberFixture(t) // paul owns, kim is a member
1172 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil {
1173 t.Fatal(err)
1174 }
1175 pipelinedApp(t, fx, "widget", "abc1234",
1176 release.PipelineChannel{Name: "edge"},
1177 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 3}})
1178
1179 if status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/promote"),
1180 url.Values{"csrf": {csrf}, "channel": {"production"}, "version": {"abc1234"}, "confirmed": {"1"}}); status != http.StatusSeeOther {
1181 t.Fatalf("owner proposal = %d -> %q:\n%s", status, loc, body)
1182 }
1183 pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
1184 if err != nil {
1185 t.Fatal(err)
1186 }
1187 if len(pending) != 1 {
1188 t.Fatalf("pending = %+v, want one", pending)
1189 }
1190 pc := pending[0]
1191
1192 kim := newBrowser(t)
1193 fx.setUser("kim@keymail.dev")
1194 signUp(t, fx, kim, "code-kim-departed")
1195 kimCSRF := sessionToken(t, fx, kim)
1196 if status, loc, body := postGated(t, fx, kim,
1197 fx.u("/apps/widget/pending/"+pc.ID+"/confirm"), url.Values{"csrf": {kimCSRF}}); status != http.StatusSeeOther {
1198 t.Fatalf("kim's confirmation = %d -> %q:\n%s", status, loc, body)
1199 }
1200
1201 // Kim leaves the account, with her confirmation still on the row.
1202 if err := fx.st.RemoveMember(OpsAccountID, "kim@keymail.dev"); err != nil {
1203 t.Fatal(err)
1204 }
1205
1206 alice := newBrowser(t)
1207 fx.setUser("alice@keymail.dev")
1208 signUp(t, fx, alice, "code-alice-departed")
1209 aliceCSRF := sessionToken(t, fx, alice)
1210 before := serial(t, fx)
1211 if status, loc, body := postGated(t, fx, alice,
1212 fx.u("/apps/widget/pending/"+pc.ID+"/confirm"), url.Values{"csrf": {aliceCSRF}}); status != http.StatusSeeOther {
1213 t.Fatalf("alice's confirmation = %d -> %q:\n%s", status, loc, body)
1214 }
1215 channelAbsent(t, fx, "widget", "production")
1216 if got := serial(t, fx); got != before {
1217 t.Fatalf("serial = %d, want it unmoved at %d — a departed member's row must not carry a change over the line", got, before)
1218 }
1219 left, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
1220 if err != nil {
1221 t.Fatal(err)
1222 }
1223 if len(left) != 1 || left[0].ID != pc.ID {
1224 t.Fatalf("pending after the refusal = %+v, want the change still listed", left)
1225 }
1226 if len(left[0].Confirms) != 3 {
1227 t.Fatalf("confirmations = %v, want all three rows KEPT — they simply do not all count", left[0].Confirms)
1228 }
1229}
1230
1231// retryProposal is the D1 approvals world: widget's production needs one
1232// approval and already serves v2, and paul proposes `promote --retry v2
1233// production` over the API. It returns the pending change with paul's
1234// confirmation recorded, ready to execute.
1235func retryProposal(t *testing.T, fx *consoleFixture) (*Account, *PendingChange) {
1236 t.Helper()
1237 pipelinedApp(t, fx, "widget", "v2",
1238 release.PipelineChannel{Name: "edge"},
1239 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 1}})
1240 seedChannel(t, fx, "widget", "production", "v2")
1241 token := cliToken(t, fx, "paul@keymail.dev", "code-retry-proposal")
1242 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/promote",
1243 map[string]any{"version": "v2", "channel": "production", "retry": true}, token)
1244 if status != http.StatusAccepted {
1245 t.Fatalf("retry into an approvals channel = %d %v, want 202 (a proposal)", status, out)
1246 }
1247 pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
1248 if err != nil || len(pending) != 1 {
1249 t.Fatalf("pending = %v, %v, want one", pending, err)
1250 }
1251 if !strings.Contains(string(pending[0].Payload), `"retry":true`) {
1252 t.Fatalf("payload = %s, want the retry carried", pending[0].Payload)
1253 }
1254 pc, met, err := fx.st.ConfirmChange(pending[0].ID, "paul@keymail.dev", time.Now())
1255 if err != nil || !met {
1256 t.Fatalf("confirm = %v, met %v", err, met)
1257 }
1258 acct, err := fx.st.AccountBySqid(fx.sqid)
1259 if err != nil || acct == nil {
1260 t.Fatalf("account: %v, %v", acct, err)
1261 }
1262 return acct, pc
1263}
1264
1265// Final review F3: an approvals-gated retry keeps its retry semantics. Once
1266// approved it RENEWS the promotion (a fresh PromotedAt on the same version),
1267// rather than landing as an ordinary promote of a version already there,
1268// which is a no-op that leaves every box's refusal standing.
1269func TestRetryProposalRenewsThePromotionOnceApproved(t *testing.T) {
1270 fx := newConsoleFixture(t)
1271 acct, pc := retryProposal(t, fx)
1272 before := fx.channelVersion(t, "widget", "production")
1273 if msg, err := fx.srv.executePending(context.Background(), acct, "widget", "paul@keymail.dev", pc); err != nil {
1274 t.Fatalf("executing the approved retry = %v (%q)", err, msg)
1275 }
1276 after := fx.channelVersion(t, "widget", "production")
1277 if after.Version != "v2" || !after.PromotedAt.After(before.PromotedAt) {
1278 t.Fatalf("production after the retry = %s at %v, want v2 renewed after %v", after.Version, after.PromotedAt, before.PromotedAt)
1279 }
1280 if len(after.Audit) == 0 || !after.Audit[0].Retry {
1281 t.Fatalf("audit = %+v, want the newest entry marked as a retry", after.Audit)
1282 }
1283}
1284
1285// ... and the retry's version guard holds at execution: a channel that moved
1286// to another version while the proposal waited is refused with the retry's
1287// own words, and the new head is left alone.
1288func TestRetryProposalRefusedWhenTheChannelMoved(t *testing.T) {
1289 fx := newConsoleFixture(t)
1290 acct, pc := retryProposal(t, fx)
1291 shipVersion(t, fx, "widget", "v3")
1292 seedChannel(t, fx, "widget", "production", "v3")
1293 _, err := fx.srv.executePending(context.Background(), acct, "widget", "paul@keymail.dev", pc)
1294 if err == nil || !strings.Contains(err.Error(), "renews a promotion") {
1295 t.Fatalf("executing a retry over a moved channel = %v, want the renews-a-promotion refusal", err)
1296 }
1297 if got := fx.channelVersion(t, "widget", "production"); got.Version != "v3" {
1298 t.Fatalf("production serves %q, want v3 untouched", got.Version)
1299 }
1300}
1301
1302// A retry whose channel does not serve the version could never execute, so
1303// the propose door refuses it before it goes out for signatures.
1304func TestRetryProposalNeedsTheHeadToBeTheVersion(t *testing.T) {
1305 fx := newConsoleFixture(t)
1306 pipelinedApp(t, fx, "widget", "v2",
1307 release.PipelineChannel{Name: "edge"},
1308 release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 1}})
1309 token := cliToken(t, fx, "paul@keymail.dev", "code-retry-no-head")
1310 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/promote",
1311 map[string]any{"version": "v2", "channel": "production", "retry": true}, token)
1312 if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), "renews a promotion: production names nothing, not v2") {
1313 t.Fatalf("retry onto a channel without the version = %d %v, want 409 naming what production serves", status, out)
1314 }
1315 if pending, _ := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()); len(pending) != 0 {
1316 t.Fatalf("pending = %v, want no proposal", pending)
1317 }
1318}
1319