| index b2ea2362c..78c137977 100644 |
| --- a/internal/console/pendingflow_test.go |
| +++ b/internal/console/pendingflow_test.go |
| @@ -676,6 +676,225 @@ func TestPendingDoorsAreNotAnOracle(t *testing.T) { |
| 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. |