| 1 | package 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 | |
| 19 | import ( |
| 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. |
| 33 | func 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. |
| 46 | func 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. |
| 58 | func 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. |
| 66 | func 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. |
| 76 | func 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. |
| 161 | func 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. |
| 226 | func 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. |
| 277 | func 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). |
| 346 | func 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. |
| 408 | func 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. |
| 526 | func 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. |
| 562 | func 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. |
| 610 | func 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. |
| 685 | func 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. |
| 738 | func 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. |
| 784 | func 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. |
| 817 | func 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). |
| 846 | func 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. |
| 901 | func 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. |
| 952 | func 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. |
| 1027 | func 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. |
| 1121 | func 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. |
| 1170 | func 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. |
| 1235 | func 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. |
| 1269 | func 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. |
| 1288 | func 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. |
| 1304 | func 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 | |