package console // Per-channel rules at the console's gates, and the pending-change flow they // open onto — release pipelines v2 §4/§5. The properties, in the order they // would hurt if lost: // // 1. A channel's declared rules decide what a promotion into it costs: a // passkey rule refuses the bearer-token door outright, an approvals rule // turns it into a PROPOSAL that has moved nothing. // 2. Quorum, once met by real passkey-backed confirmations, is the ONE place // the gated write happens — and it happens exactly once. // 3. An app with NO pipeline is untouched, byte for byte, including the // legacy stable refusal every CLI in the field already parses. // 4. A policy proposal pins the pipeline it was proposed against; a pipeline // that moved underneath it invalidates it rather than silently landing a // stale channel list. // 5. -hotfix bypasses ORDER only. It never buys past a rule. import ( "context" "net/http" "net/url" "strings" "testing" "time" "github.com/carlosframework/platform/internal/release" ) // setPipeline installs an app's pipeline through the real signed primitive — // fixture state, arranged the way the console will read it, not a promotion // or a policy edit under test. func setPipeline(t *testing.T, fx *consoleFixture, app string, channels ...release.PipelineChannel) *release.Pipeline { t.Helper() p, err := release.SetPipeline(context.Background(), fx.rel, fx.priv, release.OpsAccount, app, channels, "fixture") if err != nil { t.Fatal(err) } return p } // pipelinedApp is the world every test below needs: a claimed app, one // shipped release, that release sitting on the pipeline's entry channel, and // the pipeline itself. func pipelinedApp(t *testing.T, fx *consoleFixture, app, version string, channels ...release.PipelineChannel) { t.Helper() if _, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } shipVersion(t, fx, app, version) seedChannel(t, fx, app, "edge", version) setPipeline(t, fx, app, channels...) } // num reads a JSON number off a decoded body — every count on this wire // (needed, confirmed) arrives as a float64. func num(t *testing.T, m map[string]any, k string) int { t.Helper() v, _ := m[k].(float64) return int(v) } // channelAbsent asserts a channel pointer does not exist — the assertion that // makes a refusal true rather than merely spoken. func channelAbsent(t *testing.T, fx *consoleFixture, app, channel string) { t.Helper() if _, err := release.Channel(context.Background(), fx.rel, fx.pub, release.OpsAccount, app, channel); err == nil { t.Fatalf("channel %s exists after a refused promotion, want it absent", channel) } } // TestPipelinePromoteRulesLadder: the target channel's OWN rules decide what // the CLI door does — refuse, propose, or promote — and neither refusal nor // proposal moves anything. func TestPipelinePromoteRulesLadder(t *testing.T) { fx := newConsoleFixture(t) // A second member, so promote_approvals: 2 is a quorum this account could // actually reach. if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } pipelinedApp(t, fx, "widget", "abc1234", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Rules: release.ChannelRules{Passkey: true}}) token := cliToken(t, fx, "paul@keymail.dev", "code-rules-ladder") promoteURL := fx.ts.URL + "/api/cli/apps/bab/widget/promote" body := map[string]any{"version": "abc1234", "channel": "production"} // A passkey rule: there is no passkey at this door, so it refuses and // names the one that has one. status, out := postJSON(t, promoteURL, body, token) if status != http.StatusForbidden { t.Fatalf("promote into a passkey channel = %d %v, want 403", status, out) } if !strings.Contains(str(t, out, "error"), "production needs a passkey") { t.Fatalf("passkey refusal = %v, want it to name the channel and the passkey", out["error"]) } if !strings.Contains(str(t, out, "console_url"), "/a/bab/apps/widget") { t.Fatalf("passkey refusal console_url = %v, want the app page", out["console_url"]) } channelAbsent(t, fx, "widget", "production") // An approvals rule: the door PROPOSES, at zero confirmations — a bearer // token never counts toward a passkey quorum (spec §4). setPipeline(t, fx, "widget", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) before := serial(t, fx) status, out = postJSON(t, promoteURL, body, token) if status != http.StatusAccepted { t.Fatalf("promote into an approvals channel = %d %v, want 202", status, out) } if str(t, out, "result") != "proposed" || str(t, out, "pending_id") == "" { t.Fatalf("proposal = %v, want result proposed with a pending_id", out) } if num(t, out, "needed") != 2 || num(t, out, "confirmed") != 0 { t.Fatalf("proposal = %v, want needed 2 / confirmed 0", out) } if !strings.Contains(str(t, out, "console_url"), "/a/bab/apps/widget") { t.Fatalf("proposal console_url = %v, want the app page", out["console_url"]) } channelAbsent(t, fx, "widget", "production") if got := serial(t, fx); got != before { t.Fatalf("serial after a proposal = %d, want it unmoved at %d — nothing was written for a box to read", got, before) } // And the CLI can see what it proposed. status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pending", token) if status != http.StatusOK { t.Fatalf("pending list = %d %v", status, out) } list, _ := out["pending"].([]any) if len(list) != 1 { t.Fatalf("pending list = %v, want exactly the one proposal", out["pending"]) } row, _ := list[0].(map[string]any) if str(t, row, "kind") != "promote" || num(t, row, "needed") != 2 || num(t, row, "confirmed") != 0 { t.Fatalf("pending row = %v, want a promote at 0/2", row) } if !strings.Contains(str(t, row, "summary"), "abc1234") || !strings.Contains(str(t, row, "summary"), "production") { t.Fatalf("pending summary = %q, want it to name the version and the channel", row["summary"]) } // An UNRULED channel of the same pipeline promotes as it always did. setPipeline(t, fx, "widget", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production"}) status, out = postJSON(t, promoteURL, body, token) if status != http.StatusOK || str(t, out, "result") != "promoted" { t.Fatalf("promote into an unruled channel = %d %v, want a plain 200 promotion", status, out) } if got := fx.channelVersion(t, "widget", "production"); got.Version != "abc1234" { t.Fatalf("production serves %q, want abc1234", got.Version) } } // TestPipelineQuorumExecutesPromotion walks the whole two-member flow in the // browser: a passkey-backed proposal counts its proposer, the second member's // own passkey meets the quorum, and THAT is where the pointer moves. func TestPipelineQuorumExecutesPromotion(t *testing.T) { fx, c, csrf := memberFixture(t) // paul owns, kim is a member pipelinedApp(t, fx, "widget", "abc1234", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/promote"), url.Values{"csrf": {csrf}, "channel": {"production"}, "version": {"abc1234"}, "confirmed": {"1"}}) if status != http.StatusSeeOther { t.Fatalf("owner proposal = %d -> %q:\n%s", status, loc, body) } pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) if err != nil { t.Fatal(err) } if len(pending) != 1 { t.Fatalf("%d pending changes after a proposal, want 1", len(pending)) } pc := pending[0] if pc.Kind != "promote" || pc.Quorum != 2 { t.Fatalf("pending change = %+v, want a promote needing 2", pc) } if len(pc.Confirms) != 1 || pc.Confirms[0] != "paul@keymail.dev" { t.Fatalf("confirmations = %v, want the passkey-backed proposer counted once", pc.Confirms) } channelAbsent(t, fx, "widget", "production") // The second member confirms with their OWN passkey. kim := newBrowser(t) fx.setUser("kim@keymail.dev") signUp(t, fx, kim, "code-kim-confirm") kimCSRF := sessionToken(t, fx, kim) before := serial(t, fx) confirmURL := fx.u("/apps/widget/pending/" + pc.ID + "/confirm") status, loc, body = postGated(t, fx, kim, confirmURL, url.Values{"csrf": {kimCSRF}}) if status != http.StatusSeeOther { t.Fatalf("second confirmation = %d -> %q:\n%s", status, loc, body) } if got := fx.channelVersion(t, "widget", "production"); got.Version != "abc1234" { t.Fatalf("production serves %q after quorum, want abc1234", got.Version) } if got := serial(t, fx); got != before+1 { t.Fatalf("serial after an executed promotion = %d, want %d", got, before+1) } left, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) if err != nil { t.Fatal(err) } if len(left) != 0 { t.Fatalf("%d pending changes after execution, want the executed one gone", len(left)) } // Confirming it again is the same 404 an id that never existed gets: an // executed change is not pending, and a second confirmation must not be // able to replay the write. status, loc, _ = postForm(t, kim, confirmURL, url.Values{"csrf": {kimCSRF}}) if status != http.StatusNotFound { t.Fatalf("re-confirming an executed change = %d -> %q, want 404", status, loc) } } // TestPipelineLegacyAppsUntouched: an app with no pipeline meets exactly // today's doors. The stable refusal is the one every CLI in the field parses, // it stays byte-identical between a member and an owner, and a hotfix flag // buys nothing at it. func TestPipelineLegacyAppsUntouched(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { t.Fatal(err) } shipVersion(t, fx, "titogo", "abc1234") if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { t.Fatal(err) } owner := cliToken(t, fx, "paul@keymail.dev", "code-legacy-owner") member := cliToken(t, fx, "alice@keymail.dev", "code-legacy-member") promoteURL := fx.ts.URL + "/api/cli/apps/bab/titogo/promote" body := map[string]any{"version": "abc1234", "channel": "stable"} statusO, outO := postRaw(t, promoteURL, body, owner) statusM, outM := postRaw(t, promoteURL, body, member) if statusO != http.StatusForbidden || statusM != http.StatusForbidden { t.Fatalf("legacy stable promote = owner %d, member %d, want both 403", statusO, statusM) } if outO != outM { t.Fatalf("legacy stable refusal not byte-identical:\nowner: %q\nmember: %q", outO, outM) } if !strings.Contains(outO, "stable needs a passkey — finish in the console") { t.Fatalf("legacy stable refusal = %q, want today's exact sentence", outO) } channelAbsent(t, fx, "titogo", "stable") // The new hotfix field changes nothing about it: rules are read before // the flag ever is, and a pipeline-less app's stable rung is a rule. statusH, outH := postRaw(t, promoteURL, map[string]any{"version": "abc1234", "channel": "stable", "hotfix": true}, owner) if statusH != http.StatusForbidden || outH != outO { t.Fatalf("hotfixed legacy stable promote = %d %q, want the same 403 %q", statusH, outH, outO) } channelAbsent(t, fx, "titogo", "stable") // And below stable the legacy ladder still promotes, untouched. status, out := postJSON(t, promoteURL, map[string]any{"version": "abc1234", "channel": "edge"}, owner) if status != http.StatusOK || str(t, out, "result") != "promoted" { t.Fatalf("legacy edge promote = %d %v, want a plain 200 promotion", status, out) } if got := fx.channelVersion(t, "titogo", "edge"); got.Version != "abc1234" { t.Fatalf("edge serves %q, want abc1234", got.Version) } } // TestPolicyEditPinsSetAt: a policy proposal pins the pipeline it was read // against. A DIFFERENT write landing in between invalidates it — the channel // list it carries describes a world that no longer exists, and quietly // applying it would silently revert whatever landed meanwhile. func TestPolicyEditPinsSetAt(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } setPipeline(t, fx, "widget", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Bake: "24h", Rules: release.ChannelRules{ChangeApprovals: 1}}) token := cliToken(t, fx, "paul@keymail.dev", "code-policy-pin") pipelineURL := fx.ts.URL + "/api/cli/apps/bab/widget/pipeline" // A guarded edit proposes instead of landing. status, out := putJSON(t, pipelineURL, map[string]any{"channels": []map[string]any{ {"name": "edge"}, {"name": "production", "bake": "1h", "rules": map[string]any{"change_approvals": 1}}, }}, token) if status != http.StatusAccepted { t.Fatalf("guarded pipeline edit = %d %v, want 202", status, out) } id := str(t, out, "pending_id") if id == "" || num(t, out, "needed") != 1 || num(t, out, "confirmed") != 0 { t.Fatalf("policy proposal = %v, want a pending_id at 0/1", out) } // An UNGUARDED edit — production's own entry preserved exactly — lands // straight away, and moves the pipeline underneath the proposal. status, out = putJSON(t, pipelineURL, map[string]any{"channels": []map[string]any{ {"name": "edge", "bake": "30m"}, {"name": "production", "bake": "24h", "rules": map[string]any{"change_approvals": 1}}, }}, token) if status != http.StatusOK { t.Fatalf("unguarded pipeline edit = %d %v, want 200", status, out) } // Confirming the original to quorum: execution refuses. c := newBrowser(t) signUp(t, fx, c, "code-policy-confirm") csrf := sessionToken(t, fx, c) status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/pending/"+id+"/confirm"), url.Values{"csrf": {csrf}}) if status != http.StatusSeeOther { t.Fatalf("confirming a stale policy proposal = %d -> %q:\n%s", status, loc, body) } p, err := release.GetPipeline(context.Background(), fx.rel, fx.pub, release.OpsAccount, "widget") if err != nil || p == nil { t.Fatalf("pipeline after the refused execution = %v, %v", p, err) } if len(p.Channels) != 2 || p.Channels[0].Bake != "30m" || p.Channels[1].Bake != "24h" { t.Fatalf("pipeline after the refused execution = %+v, want the unguarded edit intact", p.Channels) } // The change stays: a refused execution is retryable, not consumed. left, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) if err != nil { t.Fatal(err) } if len(left) != 1 || left[0].ID != id { t.Fatalf("pending changes after a refused execution = %v, want the proposal still there", left) } } // TestHotfixOnlyReachesPipelineChannels closes the hole review found: on a // PIPELINED app, `stable` is not one of its channels, so it carried no rules // — and a hotfixed promote would have skipped the order check and written the // stable pointer from a bearer token, a target that has been an unconditional // 403 at this door since it existed. The flag now applies only INSIDE the // app's declared order; outside it there is no order to bypass, only a // pipeline to escape, and the sanctioned escape is a direct-bucket promote // with the root key (spec §6). func TestHotfixOnlyReachesPipelineChannels(t *testing.T) { fx := newConsoleFixture(t) pipelinedApp(t, fx, "widget", "abc1234", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "beta"}, release.PipelineChannel{Name: "production"}) token := cliToken(t, fx, "paul@keymail.dev", "code-hotfix-scope") promoteURL := fx.ts.URL + "/api/cli/apps/bab/widget/promote" // The stable RUNG, which this pipeline does not name: the byte-identical // legacy refusal, hotfix or no hotfix. plain, plainBody := postRaw(t, promoteURL, map[string]any{"version": "abc1234", "channel": "stable"}, token) hot, hotBody := postRaw(t, promoteURL, map[string]any{"version": "abc1234", "channel": "stable", "hotfix": true}, token) if plain != http.StatusForbidden || hot != http.StatusForbidden { t.Fatalf("stable on a pipelined app = plain %d, hotfixed %d, want both 403", plain, hot) } if plainBody != hotBody { t.Fatalf("the hotfix flag changed the stable refusal:\nplain: %q\nhot: %q", plainBody, hotBody) } if !strings.Contains(hotBody, "stable needs a passkey — finish in the console") { t.Fatalf("stable refusal = %q, want today's exact sentence", hotBody) } channelAbsent(t, fx, "widget", "stable") // Any other channel the pipeline does not name: refused, and the refusal // names the channels it does. status, out := postJSON(t, promoteURL, map[string]any{"version": "abc1234", "channel": "wip", "hotfix": true}, token) if status != http.StatusConflict { t.Fatalf("hotfixed promote onto an out-of-pipeline channel = %d %v, want 409", status, out) } if !strings.Contains(str(t, out, "error"), "not one of this app's channels") { t.Fatalf("out-of-pipeline refusal = %v, want it to name the pipeline's channels", out["error"]) } channelAbsent(t, fx, "widget", "wip") // canary/* is outside every pipeline BY DESIGN (spec §2) and stays // always-allowed — the fleet runs on canary channels. status, out = postJSON(t, promoteURL, map[string]any{"version": "abc1234", "channel": "canary/x"}, token) if status != http.StatusOK || str(t, out, "result") != "promoted" { t.Fatalf("canary promote on a pipelined app = %d %v, want it still allowed", status, out) } // And INSIDE the pipeline the flag does what it is for: abc1234 sits on // edge, production is entered from beta, and the flag carries it there. status, out = postJSON(t, promoteURL, map[string]any{"version": "abc1234", "channel": "production", "hotfix": true}, token) if status != http.StatusOK || str(t, out, "result") != "promoted" { t.Fatalf("hotfixed in-pipeline promote = %d %v, want 200 promoted", status, out) } if got := fx.channelVersion(t, "widget", "production"); got.Version != "abc1234" { t.Fatalf("production serves %q, want abc1234", got.Version) } } // TestRollbackMeetsChannelRules: a rollback is a pointer write, and what it // puts in front of every user of the app is a build the channel's rules exist // to govern. Review found a guarded channel could be rolled back by any // member or bearer token with no gate at all. func TestRollbackMeetsChannelRules(t *testing.T) { t.Run("api", func(t *testing.T) { fx := newConsoleFixture(t) if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } shipVersion(t, fx, "widget", "v1") shipVersion(t, fx, "widget", "v2") seedChannel(t, fx, "widget", "production", "v1") seedChannel(t, fx, "widget", "production", "v2") setPipeline(t, fx, "widget", release.PipelineChannel{Name: "production", Rules: release.ChannelRules{Passkey: true}}) token := cliToken(t, fx, "paul@keymail.dev", "code-rollback-rules") rollbackURL := fx.ts.URL + "/api/cli/apps/bab/widget/rollback" status, out := postJSON(t, rollbackURL, map[string]any{"channel": "production"}, token) if status != http.StatusForbidden { t.Fatalf("rollback of a passkey channel = %d %v, want 403", status, out) } if !strings.Contains(str(t, out, "error"), "production needs a passkey") { t.Fatalf("rollback refusal = %v, want it to name the channel and the passkey", out["error"]) } if got := fx.channelVersion(t, "widget", "production"); got.Version != "v2" { t.Fatalf("production serves %q after a refused rollback, want v2", got.Version) } // Approvals instead: the door proposes, pinned to the version the // rollback would land on. setPipeline(t, fx, "widget", release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) status, out = postJSON(t, rollbackURL, map[string]any{"channel": "production"}, token) if status != http.StatusAccepted { t.Fatalf("rollback of an approvals channel = %d %v, want 202", status, out) } if str(t, out, "result") != "proposed" || num(t, out, "needed") != 2 { t.Fatalf("rollback proposal = %v, want a proposal needing 2", out) } if got := fx.channelVersion(t, "widget", "production"); got.Version != "v2" { t.Fatalf("production serves %q after a proposal, want v2 — nothing moved", got.Version) } pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) if err != nil || len(pending) != 1 { t.Fatalf("pending = %v, %v, want one", pending, err) } if !strings.Contains(pendingSummary(pending[0]), "v1") { t.Fatalf("summary %q does not pin the version the rollback lands on", pendingSummary(pending[0])) } }) t.Run("legacy apps unchanged", func(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } shipVersion(t, fx, "titogo", "v1") shipVersion(t, fx, "titogo", "v2") seedChannel(t, fx, "titogo", "beta", "v1") seedChannel(t, fx, "titogo", "beta", "v2") token := cliToken(t, fx, "paul@keymail.dev", "code-rollback-legacy") status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/rollback", map[string]any{"channel": "beta"}, token) if status != http.StatusOK { t.Fatalf("legacy rollback = %d %v, want 200", status, out) } if got := fx.channelVersion(t, "titogo", "beta"); got.Version != "v1" { t.Fatalf("beta serves %q after a legacy rollback, want v1", got.Version) } }) t.Run("browser proposes and quorum executes", func(t *testing.T) { fx, c, csrf := memberFixture(t) // paul owns, kim is a member if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } shipVersion(t, fx, "widget", "v1") shipVersion(t, fx, "widget", "v2") seedChannel(t, fx, "widget", "production", "v1") seedChannel(t, fx, "widget", "production", "v2") setPipeline(t, fx, "widget", release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/rollback"), url.Values{"csrf": {csrf}, "channel": {"production"}, "confirmed": {"1"}}) if status != http.StatusSeeOther { t.Fatalf("browser rollback proposal = %d -> %q:\n%s", status, loc, body) } if got := fx.channelVersion(t, "widget", "production"); got.Version != "v2" { t.Fatalf("production serves %q after a proposal, want v2", got.Version) } pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) if err != nil || len(pending) != 1 || len(pending[0].Confirms) != 1 { t.Fatalf("pending = %v, %v, want one at 1 of 2", pending, err) } kim := newBrowser(t) fx.setUser("kim@keymail.dev") signUp(t, fx, kim, "code-kim-rollback") kimCSRF := sessionToken(t, fx, kim) status, loc, body = postGated(t, fx, kim, fx.u("/apps/widget/pending/"+pending[0].ID+"/confirm"), url.Values{"csrf": {kimCSRF}}) if status != http.StatusSeeOther { t.Fatalf("confirmation = %d -> %q:\n%s", status, loc, body) } if got := fx.channelVersion(t, "widget", "production"); got.Version != "v1" { t.Fatalf("production serves %q after quorum, want the rolled-back v1", got.Version) } }) } // TestExecutePendingRefusesItsOwnPreconditions: executePending is billed as // the one place a gated write happens, and Task 9 adds a caller. Neither an // under-confirmed change nor one belonging to another app may write, and // neither may panic — a bug in a caller has to refuse, not take the console // down or land an unapproved promotion. func TestExecutePendingRefusesItsOwnPreconditions(t *testing.T) { fx := newConsoleFixture(t) pipelinedApp(t, fx, "widget", "abc1234", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production"}) acct, err := fx.st.AccountBySqid(fx.sqid) if err != nil || acct == nil { t.Fatalf("account: %v, %v", acct, err) } payload := []byte(`{"version":"abc1234","channel":"production"}`) ctx := context.Background() short, err := fx.st.ProposeChange(OpsAccountID, "widget", "promote", payload, 2, "paul@keymail.dev", true, time.Now()) if err != nil { t.Fatal(err) } if msg, err := fx.srv.executePending(ctx, acct, "widget", "paul@keymail.dev", short); err == nil { t.Fatalf("executing a 1-of-2 change succeeded (%q), want a refusal", msg) } channelAbsent(t, fx, "widget", "production") elsewhere, err := fx.st.ProposeChange(OpsAccountID, "kass", "promote", payload, 1, "paul@keymail.dev", true, time.Now()) if err != nil { t.Fatal(err) } if msg, err := fx.srv.executePending(ctx, acct, "widget", "paul@keymail.dev", elsewhere); err == nil { t.Fatalf("executing another app's change succeeded (%q), want a refusal", msg) } channelAbsent(t, fx, "widget", "production") } // TestFastTrackProposesWhenGuarded: change_approvals covers overriding a // bake as well as editing a channel's entry (spec §2), and the waiver it // gates is pinned to the build it was proposed for. func TestFastTrackProposesWhenGuarded(t *testing.T) { fx, c, csrf := memberFixture(t) // paul owns, kim is a member pipelinedApp(t, fx, "widget", "abc1234", release.PipelineChannel{Name: "edge", Bake: "24h", Rules: release.ChannelRules{ChangeApprovals: 2}}) status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/fasttrack"), url.Values{"csrf": {csrf}, "channel": {"edge"}, "confirmed": {"1"}}) if status != http.StatusSeeOther { t.Fatalf("owner fast-track proposal = %d -> %q:\n%s", status, loc, body) } if p := fx.channelVersion(t, "widget", "edge"); p.FastTrack { t.Fatal("the waiver landed on one owner's say-so despite change_approvals: 2") } pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) if err != nil { t.Fatal(err) } if len(pending) != 1 || pending[0].Kind != "fasttrack" || pending[0].Quorum != 2 { t.Fatalf("pending = %+v, want one fasttrack needing 2", pending) } if !strings.Contains(pendingSummary(pending[0]), "abc1234") { t.Fatalf("summary %q does not name the pinned build", pendingSummary(pending[0])) } kim := newBrowser(t) fx.setUser("kim@keymail.dev") signUp(t, fx, kim, "code-kim-fasttrack") kimCSRF := sessionToken(t, fx, kim) status, loc, body = postGated(t, fx, kim, fx.u("/apps/widget/pending/"+pending[0].ID+"/confirm"), url.Values{"csrf": {kimCSRF}}) if status != http.StatusSeeOther { t.Fatalf("second confirmation = %d -> %q:\n%s", status, loc, body) } p := fx.channelVersion(t, "widget", "edge") if !p.FastTrack { t.Fatal("edge does not wear the waiver after quorum") } if len(p.Audit) == 0 || !p.Audit[0].FastTrack { t.Fatalf("pointer audit = %+v, want the waiver recorded", p.Audit) } } // TestPendingDoorsAreNotAnOracle: a pending change is reachable only by a // member of the account that owns it, only under its own app, and only for // rejection by its proposer or an owner. Every other shape is the same 404 an // id that never existed gets — ConfirmChange itself takes no account id, so // these doors ARE the authorization boundary. func TestPendingDoorsAreNotAnOracle(t *testing.T) { fx, c, csrf := memberFixture(t) // paul owns bab, kim is a member of it pipelinedApp(t, fx, "widget", "abc1234", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) if _, err := fx.st.CreateAppIn(OpsAccountID, "other", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } // A second account entirely, with a change of its own. if err := fx.st.CreateAccount(2, "bac", "widgets", "bob@keymail.dev", time.Now()); err != nil { t.Fatal(err) } theirs, err := fx.st.ProposeChange(2, "widget", "promote", []byte(`{"version":"abc1234","channel":"production"}`), 2, "bob@keymail.dev", false, time.Now()) if err != nil { t.Fatal(err) } status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/promote"), url.Values{"csrf": {csrf}, "channel": {"production"}, "version": {"abc1234"}, "confirmed": {"1"}}) if status != http.StatusSeeOther { t.Fatalf("proposal = %d -> %q:\n%s", status, loc, body) } pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) if err != nil || len(pending) != 1 { t.Fatalf("pending = %v, %v, want exactly one", pending, err) } id := pending[0].ID // An id that never existed sets the baseline refusal. _, _, unknown := postForm(t, c, fx.u("/apps/widget/pending/deadbeef/confirm"), url.Values{"csrf": {csrf}}) for name, u := range map[string]string{ "another account's change": fx.u("/apps/widget/pending/" + theirs.ID + "/confirm"), "the wrong app": fx.u("/apps/other/pending/" + id + "/confirm"), } { status, _, got := postForm(t, c, u, url.Values{"csrf": {csrf}}) if status != http.StatusNotFound || got != unknown { t.Fatalf("%s = %d, body %q, want the same 404 an unknown id gets", name, status, got) } } // None of that recorded a confirmation anywhere. if pc, _ := fx.st.PendingChange(theirs.ID, time.Now()); pc == nil || len(pc.Confirms) != 0 { t.Fatalf("the other account's change = %+v, want it untouched at zero confirmations", pc) } // A member who is neither proposer nor owner cannot withdraw it. kim := newBrowser(t) fx.setUser("kim@keymail.dev") signUp(t, fx, kim, "code-kim-reject") kimCSRF := sessionToken(t, fx, kim) status, _, got := postForm(t, kim, fx.u("/apps/widget/pending/"+id+"/reject"), url.Values{"csrf": {kimCSRF}}) if status != http.StatusNotFound || got != unknown { t.Fatalf("a bystander rejecting = %d, body %q, want the unknown-id 404", status, got) } if pc, _ := fx.st.PendingChange(id, time.Now()); pc == nil { t.Fatal("a bystander's rejection deleted the change") } // The proposer withdraws it — no passkey needed to REFUSE authority. status, _, body = postForm(t, c, fx.u("/apps/widget/pending/"+id+"/reject"), url.Values{"csrf": {csrf}}) if status != http.StatusSeeOther { t.Fatalf("proposer rejecting = %d:\n%s", status, body) } if pc, _ := fx.st.PendingChange(id, time.Now()); pc != nil { t.Fatalf("the change survived its proposer's rejection: %+v", pc) } channelAbsent(t, fx, "widget", "production") } // TestPromoteProposalPinsObservedHead is spec §4's other pin: "a head that // moved underneath a proposal invalidates it". A promote proposal records the // target channel's head at propose time; execution refuses when the head has // moved since — the pointer stays where whoever moved it put it, and the // change stays listed (retryable-in-principle, consistent with the policy // pin), never silently landed over a world nobody approved. func TestPromoteProposalPinsObservedHead(t *testing.T) { fx, c, csrf := memberFixture(t) // paul owns, kim is a member pipelinedApp(t, fx, "widget", "v2", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) shipVersion(t, fx, "widget", "v1") seedChannel(t, fx, "widget", "production", "v1") // head A at propose time status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/promote"), url.Values{"csrf": {csrf}, "channel": {"production"}, "version": {"v2"}, "confirmed": {"1"}}) if status != http.StatusSeeOther { t.Fatalf("proposal = %d -> %q:\n%s", status, loc, body) } pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) if err != nil || len(pending) != 1 { t.Fatalf("pending = %v, %v, want exactly one", pending, err) } pc := pending[0] if !strings.Contains(string(pc.Payload), `"observed_head":"v1"`) { t.Fatalf("payload = %s, want the observed head v1 pinned", pc.Payload) } // The head moves underneath the proposal. shipVersion(t, fx, "widget", "v3") seedChannel(t, fx, "widget", "production", "v3") // Confirming to quorum: execution refuses, nothing is written, and the // change stays listed — exactly the policy pin's shape. kim := newBrowser(t) fx.setUser("kim@keymail.dev") signUp(t, fx, kim, "code-kim-headpin") kimCSRF := sessionToken(t, fx, kim) before := serial(t, fx) status, loc, body = postGated(t, fx, kim, fx.u("/apps/widget/pending/"+pc.ID+"/confirm"), url.Values{"csrf": {kimCSRF}}) if status != http.StatusSeeOther { t.Fatalf("confirming a moved-head proposal = %d -> %q:\n%s", status, loc, body) } if got := fx.channelVersion(t, "widget", "production"); got.Version != "v3" { t.Fatalf("production serves %q after the refused execution, want v3 untouched", got.Version) } if got := serial(t, fx); got != before { t.Fatalf("serial = %d after a refused execution, want %d — nothing was written for a box to read", got, before) } left, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) if err != nil || len(left) != 1 || left[0].ID != pc.ID { t.Fatalf("pending after a refused execution = %v, %v, want the proposal still there", left, err) } } // TestPromoteAPIPinsObservedHead: the bearer-token doors pin the head too — // the API promote door records the target channel's current version, and // omits the field entirely for a channel with no pointer yet. func TestPromoteAPIPinsObservedHead(t *testing.T) { fx := newConsoleFixture(t) if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } pipelinedApp(t, fx, "widget", "v2", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) token := cliToken(t, fx, "paul@keymail.dev", "code-head-pin-api") promoteURL := fx.ts.URL + "/api/cli/apps/bab/widget/promote" // production has NO pointer yet: the proposal carries no pin. status, out := postJSON(t, promoteURL, map[string]any{"version": "v2", "channel": "production"}, token) if status != http.StatusAccepted { t.Fatalf("promote proposal = %d %v, want 202", status, out) } pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) if err != nil || len(pending) != 1 { t.Fatalf("pending = %v, %v, want one", pending, err) } if strings.Contains(string(pending[0].Payload), "observed_head") { t.Fatalf("payload = %s, want NO observed_head for a channel with no pointer", pending[0].Payload) } if err := fx.st.DeleteChange(pending[0].ID); err != nil { t.Fatal(err) } // With a head in place, the proposal pins it. shipVersion(t, fx, "widget", "v1") seedChannel(t, fx, "widget", "production", "v1") status, out = postJSON(t, promoteURL, map[string]any{"version": "v2", "channel": "production"}, token) if status != http.StatusAccepted { t.Fatalf("promote proposal = %d %v, want 202", status, out) } pending, err = fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) if err != nil || len(pending) != 1 { t.Fatalf("pending = %v, %v, want one", pending, err) } if !strings.Contains(string(pending[0].Payload), `"observed_head":"v1"`) { t.Fatalf("payload = %s, want the observed head v1 pinned", pending[0].Payload) } } // TestRollbackProposalPinsObservedHead: the rollback doors pin the head they // read — the version the rollback is FROM — so a channel that moved again // while the proposal waited invalidates it at execution. func TestRollbackProposalPinsObservedHead(t *testing.T) { fx := newConsoleFixture(t) if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } shipVersion(t, fx, "widget", "v1") shipVersion(t, fx, "widget", "v2") seedChannel(t, fx, "widget", "production", "v1") seedChannel(t, fx, "widget", "production", "v2") setPipeline(t, fx, "widget", release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) token := cliToken(t, fx, "paul@keymail.dev", "code-head-pin-rollback") status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/rollback", map[string]any{"channel": "production"}, token) if status != http.StatusAccepted { t.Fatalf("rollback proposal = %d %v, want 202", status, out) } pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) if err != nil || len(pending) != 1 { t.Fatalf("pending = %v, %v, want one", pending, err) } if !strings.Contains(string(pending[0].Payload), `"observed_head":"v2"`) { t.Fatalf("payload = %s, want the head being rolled back from (v2) pinned", pending[0].Payload) } } // TestPromoteOldRowWithoutObservedHeadExecutes: a row written before the // observed_head field existed carries no pin and keeps its old behaviour — // wire-shape discipline for rows already sitting in production consoles. func TestPromoteOldRowWithoutObservedHeadExecutes(t *testing.T) { fx := newConsoleFixture(t) pipelinedApp(t, fx, "widget", "v2", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 1}}) shipVersion(t, fx, "widget", "v1") seedChannel(t, fx, "widget", "production", "v1") acct, err := fx.st.AccountBySqid(fx.sqid) if err != nil || acct == nil { t.Fatalf("account: %v, %v", acct, err) } // The pre-change payload shape, verbatim: no observed_head. pc, err := fx.st.ProposeChange(OpsAccountID, "widget", "promote", []byte(`{"version":"v2","channel":"production"}`), 1, "paul@keymail.dev", true, time.Now()) if err != nil { t.Fatal(err) } if msg, err := fx.srv.executePending(context.Background(), acct, "widget", "paul@keymail.dev", pc); err != nil { t.Fatalf("executing an old row without the pin = %v (%q), want it to land as before", err, msg) } if got := fx.channelVersion(t, "widget", "production"); got.Version != "v2" { t.Fatalf("production serves %q, want v2 — old rows keep current behaviour", got.Version) } } // TestRollbackExecutionAuditSaysRolledBack: the card says "roll back", so the // execution's audit row must too — "promoted v1 to production" for a rollback // is illegible to whoever reads the trail later (same Task 8 argument that // fixed the summary). func TestRollbackExecutionAuditSaysRolledBack(t *testing.T) { fx, c, csrf := memberFixture(t) // paul owns, kim is a member if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } shipVersion(t, fx, "widget", "v1") shipVersion(t, fx, "widget", "v2") seedChannel(t, fx, "widget", "production", "v1") seedChannel(t, fx, "widget", "production", "v2") setPipeline(t, fx, "widget", release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/rollback"), url.Values{"csrf": {csrf}, "channel": {"production"}, "confirmed": {"1"}}) if status != http.StatusSeeOther { t.Fatalf("rollback proposal = %d -> %q:\n%s", status, loc, body) } pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) if err != nil || len(pending) != 1 { t.Fatalf("pending = %v, %v, want one", pending, err) } kim := newBrowser(t) fx.setUser("kim@keymail.dev") signUp(t, fx, kim, "code-kim-audit") kimCSRF := sessionToken(t, fx, kim) status, loc, body = postGated(t, fx, kim, fx.u("/apps/widget/pending/"+pending[0].ID+"/confirm"), url.Values{"csrf": {kimCSRF}}) if status != http.StatusSeeOther { t.Fatalf("confirmation = %d -> %q:\n%s", status, loc, body) } if got := fx.channelVersion(t, "widget", "production"); got.Version != "v1" { t.Fatalf("production serves %q after quorum, want the rolled-back v1", got.Version) } events, err := fx.st.Events(OpsAccountID, "widget", 50) if err != nil { t.Fatal(err) } var found bool for _, ev := range events { if strings.Contains(ev.Detail, "promoted v1 to production") { t.Fatalf("audit row %q says promoted for a rollback proposal", ev.Detail) } if ev.Action == "rollback" && strings.Contains(ev.Detail, "rolled back production to v1") && strings.Contains(ev.Detail, "proposed by") { found = true } } if !found { t.Fatalf("no rollback-voiced audit row in %+v", events) } } // TestHotfixBypassesOrderNotRules is spec §5's exact sentence, twice over: // the flag never buys past a channel's rules, and it does bypass the order // on a channel that carries none. func TestHotfixBypassesOrderNotRules(t *testing.T) { fx := newConsoleFixture(t) pipelinedApp(t, fx, "widget", "abc1234", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Rules: release.ChannelRules{Passkey: true}}) token := cliToken(t, fx, "paul@keymail.dev", "code-hotfix-rules") promoteURL := fx.ts.URL + "/api/cli/apps/bab/widget/promote" status, out := postJSON(t, promoteURL, map[string]any{"version": "abc1234", "channel": "production", "hotfix": true}, token) if status != http.StatusForbidden { t.Fatalf("hotfixed promote into a passkey channel = %d %v, want the 403 passkey refusal", status, out) } if !strings.Contains(str(t, out, "error"), "passkey") { t.Fatalf("hotfixed refusal = %v, want it to name the passkey", out["error"]) } channelAbsent(t, fx, "widget", "production") // An out-of-order target with no rules at all: refused without the flag, // allowed with it. setPipeline(t, fx, "widget", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "beta"}, release.PipelineChannel{Name: "production"}) status, out = postJSON(t, promoteURL, map[string]any{"version": "abc1234", "channel": "production"}, token) if status != http.StatusConflict { t.Fatalf("out-of-order promote = %d %v, want 409", status, out) } if !strings.Contains(str(t, out, "error"), "beta") { t.Fatalf("order refusal = %v, want it to name the predecessor", out["error"]) } channelAbsent(t, fx, "widget", "production") status, out = postJSON(t, promoteURL, map[string]any{"version": "abc1234", "channel": "production", "hotfix": true}, token) if status != http.StatusOK || str(t, out, "result") != "promoted" { t.Fatalf("hotfixed out-of-order promote = %d %v, want 200 promoted", status, out) } if got := fx.channelVersion(t, "widget", "production"); got.Version != "abc1234" { t.Fatalf("production serves %q, want abc1234", got.Version) } } // TestExecutionMeetsTheCURRENTQuorum: a proposal carries the quorum that was // in force when it was made, and a week can pass before it is confirmed. If // the channel's rule was TIGHTENED in between — 2 approvals to 3 — executing // at the stored 2 would let a stale proposal be the way past the new rule. // Execution therefore re-reads the pipeline and requires max(stored, current). // The refusal is an error, not a consumption: the change stays listed and // stays retryable, so the third member can still meet it. func TestExecutionMeetsTheCURRENTQuorum(t *testing.T) { fx, c, csrf := memberFixture(t) // paul owns, kim is a member if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } pipelinedApp(t, fx, "widget", "abc1234", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/promote"), url.Values{"csrf": {csrf}, "channel": {"production"}, "version": {"abc1234"}, "confirmed": {"1"}}) if status != http.StatusSeeOther { t.Fatalf("owner proposal = %d -> %q:\n%s", status, loc, body) } pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) if err != nil { t.Fatal(err) } if len(pending) != 1 || pending[0].Quorum != 2 { t.Fatalf("pending = %+v, want one promote needing 2", pending) } pc := pending[0] // The rule is raised while the proposal waits. setPipeline(t, fx, "widget", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 3}}) kim := newBrowser(t) fx.setUser("kim@keymail.dev") signUp(t, fx, kim, "code-kim-tightened") kimCSRF := sessionToken(t, fx, kim) before := serial(t, fx) status, loc, body = postGated(t, fx, kim, fx.u("/apps/widget/pending/"+pc.ID+"/confirm"), url.Values{"csrf": {kimCSRF}}) if status != http.StatusSeeOther { t.Fatalf("second confirmation = %d -> %q:\n%s", status, loc, body) } channelAbsent(t, fx, "widget", "production") if got := serial(t, fx); got != before { t.Fatalf("serial = %d, want it unmoved at %d — nothing may be written under a quorum that is no longer enough", got, before) } left, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) if err != nil { t.Fatal(err) } if len(left) != 1 || left[0].ID != pc.ID { t.Fatalf("pending after the refusal = %+v, want the change still listed and retryable", left) } if len(left[0].Confirms) != 2 { t.Fatalf("confirmations = %v, want both kept — a refusal must not discard them", left[0].Confirms) } // The third member meets the new rule, and THAT lands it. alice := newBrowser(t) fx.setUser("alice@keymail.dev") signUp(t, fx, alice, "code-alice-tightened") aliceCSRF := sessionToken(t, fx, alice) status, loc, body = postGated(t, fx, alice, fx.u("/apps/widget/pending/"+pc.ID+"/confirm"), url.Values{"csrf": {aliceCSRF}}) if status != http.StatusSeeOther { t.Fatalf("third confirmation = %d -> %q:\n%s", status, loc, body) } if got := fx.channelVersion(t, "widget", "production"); got.Version != "abc1234" { t.Fatalf("production serves %q after the raised quorum was met, want abc1234", got.Version) } } // TestPendingTalliesShowWhatExecutionRequires: the card, the CLI list and the // confirm flash must not promise a quorum execution will refuse. With the // rule raised from 2 to 3 underneath a live proposal, every surface has to // read 2 of 3 — a change sitting at "2 of 2" beside a Confirm button that // refuses every time it is pressed is worse than no card at all (PR #258 // review, I3). And a confirmation from a departed member has to stop counting // on those same surfaces, for the same reason. func TestPendingTalliesShowWhatExecutionRequires(t *testing.T) { fx, c, csrf := memberFixture(t) // paul owns, kim is a member if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } pipelinedApp(t, fx, "widget", "abc1234", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) if status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/promote"), url.Values{"csrf": {csrf}, "channel": {"production"}, "version": {"abc1234"}, "confirmed": {"1"}}); status != http.StatusSeeOther { t.Fatalf("owner proposal = %d -> %q:\n%s", status, loc, body) } // Before the tightening the card reads 1/2 — pending.html's own format, // asserted here so the checks after the tightening are known to be // looking at the card and not at some flash that happens to say "of". _, _, body := get(t, c, fx.u("/apps/widget")) if !strings.Contains(body, "1/2 confirmed") { t.Fatalf("card does not show 1/2 confirmed before the tightening:\n%s", body) } setPipeline(t, fx, "widget", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 3}}) // The card. _, _, body = get(t, c, fx.u("/apps/widget")) if !strings.Contains(body, "1/3 confirmed") { t.Errorf("card shows the stored quorum, not the one execution now requires:\n%s", body) } if strings.Contains(body, "1/2 confirmed") { t.Errorf("card still shows the superseded 1/2:\n%s", body) } // The CLI list. token := cliToken(t, fx, "paul@keymail.dev", "code-tally-list") status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pending", token) if status != http.StatusOK { t.Fatalf("pending list = %d %v", status, out) } list, _ := out["pending"].([]any) if len(list) != 1 { t.Fatalf("pending list = %v, want one row", out["pending"]) } row, _ := list[0].(map[string]any) if num(t, row, "needed") != 3 || num(t, row, "confirmed") != 1 { t.Errorf("CLI row = %v, want 1 of 3", row) } // The confirm flash, and the audit row behind it: kim's confirmation // takes it to 2 of 3, not "2 of 2, executed". pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) if err != nil { t.Fatal(err) } kim := newBrowser(t) fx.setUser("kim@keymail.dev") signUp(t, fx, kim, "code-kim-tally") kimCSRF := sessionToken(t, fx, kim) if status, loc, body := postGated(t, fx, kim, fx.u("/apps/widget/pending/"+pending[0].ID+"/confirm"), url.Values{"csrf": {kimCSRF}}); status != http.StatusSeeOther { t.Fatalf("second confirmation = %d -> %q:\n%s", status, loc, body) } _, _, kimBody := get(t, kim, fx.u("/apps/widget")) if !strings.Contains(kimBody, "2/3 confirmed") { t.Errorf("after the second confirmation the card does not read 2/3:\n%s", kimBody) } events, err := fx.st.Events(OpsAccountID, "widget", 10) if err != nil { t.Fatal(err) } found := false for _, e := range events { if e.Action == "confirmed" && strings.Contains(e.Detail, "2 of 3 confirmations") { found = true } } if !found { t.Errorf("no audit row recording 2 of 3 confirmations: %+v", events) } // And a departed member's row stops counting on the surfaces too. if err := fx.st.RemoveMember(OpsAccountID, "kim@keymail.dev"); err != nil { t.Fatal(err) } _, _, body = get(t, c, fx.u("/apps/widget")) if !strings.Contains(body, "1/3 confirmed") { t.Errorf("card counts a departed member's confirmation:\n%s", body) } } // TestLoosenedRuleDoesNotLowerAStoredQuorum: the ratchet only goes one way. // A proposal made at 3 approvals is not executable at 2 because somebody // lowered the rule afterwards — the confirmers agreed to a change that needed // three of them, and lowering the rule is not their confirmation. func TestLoosenedRuleDoesNotLowerAStoredQuorum(t *testing.T) { fx, c, csrf := memberFixture(t) // paul owns, kim is a member if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } pipelinedApp(t, fx, "widget", "abc1234", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 3}}) if status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/promote"), url.Values{"csrf": {csrf}, "channel": {"production"}, "version": {"abc1234"}, "confirmed": {"1"}}); status != http.StatusSeeOther { t.Fatalf("owner proposal = %d -> %q:\n%s", status, loc, body) } pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) if err != nil { t.Fatal(err) } if len(pending) != 1 || pending[0].Quorum != 3 { t.Fatalf("pending = %+v, want one promote needing 3", pending) } pc := pending[0] setPipeline(t, fx, "widget", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) kim := newBrowser(t) fx.setUser("kim@keymail.dev") signUp(t, fx, kim, "code-kim-loosened") kimCSRF := sessionToken(t, fx, kim) if status, loc, body := postGated(t, fx, kim, fx.u("/apps/widget/pending/"+pc.ID+"/confirm"), url.Values{"csrf": {kimCSRF}}); status != http.StatusSeeOther { t.Fatalf("second confirmation = %d -> %q:\n%s", status, loc, body) } channelAbsent(t, fx, "widget", "production") left, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) if err != nil { t.Fatal(err) } if len(left) != 1 { t.Fatalf("pending after the loosened rule = %+v, want the change still waiting on its own quorum of 3", left) } } // TestDepartedMemberConfirmDoesNotCount: a quorum is N distinct MEMBERS. An // address that has since left the account is not one, so its row must not // carry a change over the line — otherwise removing somebody from an account // would leave their standing approvals working on their way out. The row is // kept (the trail of who confirmed what stays true); it simply stops counting. func TestDepartedMemberConfirmDoesNotCount(t *testing.T) { fx, c, csrf := memberFixture(t) // paul owns, kim is a member if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } pipelinedApp(t, fx, "widget", "abc1234", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 3}}) if status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/promote"), url.Values{"csrf": {csrf}, "channel": {"production"}, "version": {"abc1234"}, "confirmed": {"1"}}); status != http.StatusSeeOther { t.Fatalf("owner proposal = %d -> %q:\n%s", status, loc, body) } pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) if err != nil { t.Fatal(err) } if len(pending) != 1 { t.Fatalf("pending = %+v, want one", pending) } pc := pending[0] kim := newBrowser(t) fx.setUser("kim@keymail.dev") signUp(t, fx, kim, "code-kim-departed") kimCSRF := sessionToken(t, fx, kim) if status, loc, body := postGated(t, fx, kim, fx.u("/apps/widget/pending/"+pc.ID+"/confirm"), url.Values{"csrf": {kimCSRF}}); status != http.StatusSeeOther { t.Fatalf("kim's confirmation = %d -> %q:\n%s", status, loc, body) } // Kim leaves the account, with her confirmation still on the row. if err := fx.st.RemoveMember(OpsAccountID, "kim@keymail.dev"); err != nil { t.Fatal(err) } alice := newBrowser(t) fx.setUser("alice@keymail.dev") signUp(t, fx, alice, "code-alice-departed") aliceCSRF := sessionToken(t, fx, alice) before := serial(t, fx) if status, loc, body := postGated(t, fx, alice, fx.u("/apps/widget/pending/"+pc.ID+"/confirm"), url.Values{"csrf": {aliceCSRF}}); status != http.StatusSeeOther { t.Fatalf("alice's confirmation = %d -> %q:\n%s", status, loc, body) } channelAbsent(t, fx, "widget", "production") if got := serial(t, fx); got != before { t.Fatalf("serial = %d, want it unmoved at %d — a departed member's row must not carry a change over the line", got, before) } left, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) if err != nil { t.Fatal(err) } if len(left) != 1 || left[0].ID != pc.ID { t.Fatalf("pending after the refusal = %+v, want the change still listed", left) } if len(left[0].Confirms) != 3 { t.Fatalf("confirmations = %v, want all three rows KEPT — they simply do not all count", left[0].Confirms) } } // retryProposal is the D1 approvals world: widget's production needs one // approval and already serves v2, and paul proposes `promote --retry v2 // production` over the API. It returns the pending change with paul's // confirmation recorded, ready to execute. func retryProposal(t *testing.T, fx *consoleFixture) (*Account, *PendingChange) { t.Helper() pipelinedApp(t, fx, "widget", "v2", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 1}}) seedChannel(t, fx, "widget", "production", "v2") token := cliToken(t, fx, "paul@keymail.dev", "code-retry-proposal") status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/promote", map[string]any{"version": "v2", "channel": "production", "retry": true}, token) if status != http.StatusAccepted { t.Fatalf("retry into an approvals channel = %d %v, want 202 (a proposal)", status, out) } pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) if err != nil || len(pending) != 1 { t.Fatalf("pending = %v, %v, want one", pending, err) } if !strings.Contains(string(pending[0].Payload), `"retry":true`) { t.Fatalf("payload = %s, want the retry carried", pending[0].Payload) } pc, met, err := fx.st.ConfirmChange(pending[0].ID, "paul@keymail.dev", time.Now()) if err != nil || !met { t.Fatalf("confirm = %v, met %v", err, met) } acct, err := fx.st.AccountBySqid(fx.sqid) if err != nil || acct == nil { t.Fatalf("account: %v, %v", acct, err) } return acct, pc } // Final review F3: an approvals-gated retry keeps its retry semantics. Once // approved it RENEWS the promotion (a fresh PromotedAt on the same version), // rather than landing as an ordinary promote of a version already there, // which is a no-op that leaves every box's refusal standing. func TestRetryProposalRenewsThePromotionOnceApproved(t *testing.T) { fx := newConsoleFixture(t) acct, pc := retryProposal(t, fx) before := fx.channelVersion(t, "widget", "production") if msg, err := fx.srv.executePending(context.Background(), acct, "widget", "paul@keymail.dev", pc); err != nil { t.Fatalf("executing the approved retry = %v (%q)", err, msg) } after := fx.channelVersion(t, "widget", "production") if after.Version != "v2" || !after.PromotedAt.After(before.PromotedAt) { t.Fatalf("production after the retry = %s at %v, want v2 renewed after %v", after.Version, after.PromotedAt, before.PromotedAt) } if len(after.Audit) == 0 || !after.Audit[0].Retry { t.Fatalf("audit = %+v, want the newest entry marked as a retry", after.Audit) } } // ... and the retry's version guard holds at execution: a channel that moved // to another version while the proposal waited is refused with the retry's // own words, and the new head is left alone. func TestRetryProposalRefusedWhenTheChannelMoved(t *testing.T) { fx := newConsoleFixture(t) acct, pc := retryProposal(t, fx) shipVersion(t, fx, "widget", "v3") seedChannel(t, fx, "widget", "production", "v3") _, err := fx.srv.executePending(context.Background(), acct, "widget", "paul@keymail.dev", pc) if err == nil || !strings.Contains(err.Error(), "renews a promotion") { t.Fatalf("executing a retry over a moved channel = %v, want the renews-a-promotion refusal", err) } if got := fx.channelVersion(t, "widget", "production"); got.Version != "v3" { t.Fatalf("production serves %q, want v3 untouched", got.Version) } } // A retry whose channel does not serve the version could never execute, so // the propose door refuses it before it goes out for signatures. func TestRetryProposalNeedsTheHeadToBeTheVersion(t *testing.T) { fx := newConsoleFixture(t) pipelinedApp(t, fx, "widget", "v2", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 1}}) token := cliToken(t, fx, "paul@keymail.dev", "code-retry-no-head") status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/promote", map[string]any{"version": "v2", "channel": "production", "retry": true}, token) if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), "renews a promotion: production names nothing, not v2") { t.Fatalf("retry onto a channel without the version = %d %v, want 409 naming what production serves", status, out) } if pending, _ := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()); len(pending) != 0 { t.Fatalf("pending = %v, want no proposal", pending) } }