| 1 | package console |
| 2 | |
| 3 | // The bake controls (2026-08-04 console bake controls spec §7). Properties |
| 4 | // in the order they'd hurt if lost: OFF cannot happen without owner + |
| 5 | // passkey (and a member probing it gets the byte-identical 404); ON is any |
| 6 | // member's, instantly; fast-track is owner + passkey and lands on the |
| 7 | // pointer; the stable-promote checkbox flips the flag in the same confirmed |
| 8 | // action. |
| 9 | |
| 10 | import ( |
| 11 | "context" |
| 12 | "net/http" |
| 13 | "net/url" |
| 14 | "strings" |
| 15 | "testing" |
| 16 | "time" |
| 17 | |
| 18 | "github.com/carlosframework/platform/internal/release" |
| 19 | ) |
| 20 | |
| 21 | // productionFlag reads the app's flag straight from the bucket, through the |
| 22 | // same verification the adopt path uses. The fixture carries the release |
| 23 | // public key, so there is nothing to derive. |
| 24 | func productionFlag(t *testing.T, fx *consoleFixture, app string) *release.ProductionFlag { |
| 25 | t.Helper() |
| 26 | f, err := release.GetProduction(context.Background(), fx.rel, fx.pub, release.OpsAccount, app) |
| 27 | if err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | return f |
| 31 | } |
| 32 | |
| 33 | // strangerRefusal is what someone with no membership at all gets from one of |
| 34 | // this account's app routes: a signed-in browser belonging to nobody here. |
| 35 | // It is the yardstick the member's refusal must match byte for byte. |
| 36 | func strangerRefusal(t *testing.T, fx *consoleFixture, code, u string, form url.Values) string { |
| 37 | t.Helper() |
| 38 | fx.fk.mu.Lock() |
| 39 | prev := fx.fk.address |
| 40 | fx.fk.mu.Unlock() |
| 41 | fx.setUser("zoe@keymail.dev") // signs up into an account of her own |
| 42 | defer fx.setUser(prev) |
| 43 | c := newBrowser(t) |
| 44 | signUp(t, fx, c, code) |
| 45 | form.Set("csrf", sessionToken(t, fx, c)) |
| 46 | status, _, body := postForm(t, c, u, form) |
| 47 | if status != http.StatusNotFound { |
| 48 | t.Fatalf("non-member posting %s = %d, want 404", u, status) |
| 49 | } |
| 50 | return body |
| 51 | } |
| 52 | |
| 53 | // auditedBy asserts the console's OWN activity trail (store events, which is |
| 54 | // what the app page renders) recorded this action for this actor. The |
| 55 | // pointer's audit trail is a different record with a different purpose; a |
| 56 | // console action that leaves no console event is an invisible one. |
| 57 | func auditedBy(t *testing.T, fx *consoleFixture, app, action, actor, detailPart string) { |
| 58 | t.Helper() |
| 59 | events, err := fx.st.Events(OpsAccountID, app, 20) |
| 60 | if err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | for _, e := range events { |
| 64 | if e.Action == action && e.Address == actor && strings.Contains(e.Detail, detailPart) { |
| 65 | return |
| 66 | } |
| 67 | } |
| 68 | t.Fatalf("events = %+v, want a %q row by %s mentioning %q", events, action, actor, detailPart) |
| 69 | } |
| 70 | |
| 71 | func TestProductionOnIsAnyMembers(t *testing.T) { |
| 72 | fx := newConsoleFixture(t) |
| 73 | if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | fx.setUser("sam@keymail.dev") |
| 77 | c := newBrowser(t) |
| 78 | signUp(t, fx, c, "code-sam-bake") |
| 79 | csrf := sessionToken(t, fx, c) |
| 80 | |
| 81 | status, _, _ := postForm(t, c, fx.u("/apps/kass/production"), |
| 82 | url.Values{"csrf": {csrf}, "production": {"on"}}) |
| 83 | if status != http.StatusSeeOther { |
| 84 | t.Fatalf("member turning bake ON = %d, want a plain redirect (no step-up)", status) |
| 85 | } |
| 86 | if f := productionFlag(t, fx, "kass"); f == nil || !f.Production { |
| 87 | t.Fatalf("flag = %+v, want production on", f) |
| 88 | } |
| 89 | auditedBy(t, fx, "kass", "production", "sam@keymail.dev", "production: on") |
| 90 | } |
| 91 | |
| 92 | func TestProductionOffIsOwnerPasskeyGated(t *testing.T) { |
| 93 | fx := newConsoleFixture(t) |
| 94 | c := signInPaul(t, fx) |
| 95 | csrf := sessionToken(t, fx, c) |
| 96 | // Seed on, as any member could. |
| 97 | if err := release.SetProduction(context.Background(), fx.rel, fx.priv, |
| 98 | release.OpsAccount, "kass", true, "test"); err != nil { |
| 99 | t.Fatal(err) |
| 100 | } |
| 101 | |
| 102 | // Without the passkey the flag does not move. |
| 103 | status, loc, _ := postForm(t, c, fx.u("/apps/kass/production"), |
| 104 | url.Values{"csrf": {csrf}, "production": {"off"}}) |
| 105 | if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 106 | t.Fatalf("un-stepped-up off = %d -> %q, want a redirect into Keymail", status, loc) |
| 107 | } |
| 108 | if f := productionFlag(t, fx, "kass"); f == nil || !f.Production { |
| 109 | t.Fatal("the flag flipped off with no passkey confirmation") |
| 110 | } |
| 111 | // With it, the held action resumes. |
| 112 | if status, _, body := completeStepUp(t, fx, c, loc); status != http.StatusSeeOther { |
| 113 | t.Fatalf("resumed off = %d:\n%s", status, body) |
| 114 | } |
| 115 | if f := productionFlag(t, fx, "kass"); f == nil || f.Production { |
| 116 | t.Fatal("the flag did not flip off after confirmation") |
| 117 | } |
| 118 | auditedBy(t, fx, "kass", "production", "paul@keymail.dev", "production: off") |
| 119 | } |
| 120 | |
| 121 | // A MEMBER probing the off direction meets the byte-identical 404 — before |
| 122 | // any step-up redirect could confirm the account exists. |
| 123 | func TestProductionOffMemberGets404(t *testing.T) { |
| 124 | fx := newConsoleFixture(t) |
| 125 | // Seeded ON, so "the flag stayed put" is an assertion about a value that |
| 126 | // could have moved rather than about an absent object. |
| 127 | if err := release.SetProduction(context.Background(), fx.rel, fx.priv, |
| 128 | release.OpsAccount, "kass", true, "test"); err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | fx.setUser("sam@keymail.dev") |
| 135 | c := newBrowser(t) |
| 136 | signUp(t, fx, c, "code-sam-bake2") |
| 137 | csrf := sessionToken(t, fx, c) |
| 138 | form := url.Values{"csrf": {csrf}, "production": {"off"}} |
| 139 | status, _, memberBody := postForm(t, c, fx.u("/apps/kass/production"), form) |
| 140 | if status != http.StatusNotFound { |
| 141 | t.Fatalf("member turning bake OFF = %d, want 404", status) |
| 142 | } |
| 143 | if f := productionFlag(t, fx, "kass"); f == nil || !f.Production { |
| 144 | t.Fatalf("flag = %+v after a member's refused post, want it untouched", f) |
| 145 | } |
| 146 | // Byte-identical, not merely same-status: the difference between "not |
| 147 | // allowed" and "no such account" is not information this hands out. |
| 148 | strangerBody := strangerRefusal(t, fx, "code-zoe-bake2", fx.u("/apps/kass/production"), |
| 149 | url.Values{"production": {"off"}}) |
| 150 | if memberBody != strangerBody { |
| 151 | t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody) |
| 152 | } |
| 153 | // http.NotFound's own, well-known output — pinned so a future change to |
| 154 | // either path is caught even if it stays self-consistent. |
| 155 | if memberBody != "404 page not found\n" { |
| 156 | t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", memberBody) |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // Fast-track is owner + passkey, ALWAYS. The un-stepped-up post is what |
| 161 | // proves the second half: postGated alone would stay green with the step-up |
| 162 | // call deleted, because it only walks the round trip when one is demanded. |
| 163 | func TestFastTrackIsOwnerPasskeyGatedAndLands(t *testing.T) { |
| 164 | fx := newConsoleFixture(t) |
| 165 | c := signInPaul(t, fx) |
| 166 | csrf := sessionToken(t, fx, c) |
| 167 | |
| 168 | // The fixture's stable already points at v1. Without the passkey the |
| 169 | // waiver does not happen. |
| 170 | status, loc, _ := postForm(t, c, fx.u("/apps/kass/fasttrack"), |
| 171 | url.Values{"csrf": {csrf}, "channel": {"stable"}, "confirmed": {"1"}}) |
| 172 | if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 173 | t.Fatalf("un-stepped-up fast-track = %d -> %q, want a redirect into Keymail", status, loc) |
| 174 | } |
| 175 | if p := fx.channelVersion(t, "kass", "stable"); p.FastTrack { |
| 176 | t.Fatal("the bake window was waived with no passkey confirmation") |
| 177 | } |
| 178 | |
| 179 | // With it, the held action resumes and lands on the pointer. |
| 180 | if status, _, body := completeStepUp(t, fx, c, loc); status != http.StatusSeeOther { |
| 181 | t.Fatalf("resumed fast-track = %d:\n%s", status, body) |
| 182 | } |
| 183 | p := fx.channelVersion(t, "kass", "stable") |
| 184 | if !p.FastTrack { |
| 185 | t.Fatalf("pointer = %+v, want fast_track set", p) |
| 186 | } |
| 187 | if len(p.Audit) == 0 || !p.Audit[0].FastTrack || p.Audit[0].Actor != "paul@keymail.dev" { |
| 188 | t.Fatalf("audit[0] = %+v, want the waiver recorded with its actor", p.Audit) |
| 189 | } |
| 190 | auditedBy(t, fx, "kass", "fast-track", "paul@keymail.dev", "skipped the safety delay for v1 on stable") |
| 191 | } |
| 192 | |
| 193 | // Fast-tracking a channel that never bakes is refused. It looks inert — |
| 194 | // adopt ignores the waiver on a zero-hold channel — but the re-sign stamps a |
| 195 | // fresh PromotedAt, which is exactly what the canary TTL lease is measured |
| 196 | // from: the post would quietly renew the lease and leave a permanent |
| 197 | // "fast-tracked past the bake window" badge on a channel that has no window. |
| 198 | func TestFastTrackRefusesAChannelWithNoBakeWindow(t *testing.T) { |
| 199 | fx := newConsoleFixture(t) |
| 200 | shipVersion(t, fx, "kass", "v3") |
| 201 | seedChannel(t, fx, "kass", "canary/x", "v3") |
| 202 | before := fx.channelVersion(t, "kass", "canary/x") |
| 203 | c := signInPaul(t, fx) |
| 204 | csrf := sessionToken(t, fx, c) |
| 205 | |
| 206 | status, _, _ := postGated(t, fx, c, fx.u("/apps/kass/fasttrack"), |
| 207 | url.Values{"csrf": {csrf}, "channel": {"canary/x"}, "confirmed": {"1"}}) |
| 208 | if status != http.StatusSeeOther { |
| 209 | t.Fatalf("fast-track of a bake-free channel = %d, want the flash redirect", status) |
| 210 | } |
| 211 | if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, |
| 212 | "Channel canary/x has no safety delay running — there is nothing to skip.") { |
| 213 | t.Fatalf("no refusal flash:\n%s", body) |
| 214 | } |
| 215 | after := fx.channelVersion(t, "kass", "canary/x") |
| 216 | if after.FastTrack { |
| 217 | t.Fatal("a bake-free channel wears the waiver badge") |
| 218 | } |
| 219 | if !after.PromotedAt.Equal(before.PromotedAt) { |
| 220 | t.Fatalf("PromotedAt moved %s -> %s — the refused post renewed the canary's TTL lease", |
| 221 | before.PromotedAt, after.PromotedAt) |
| 222 | } |
| 223 | if len(after.Audit) != len(before.Audit) { |
| 224 | t.Fatalf("audit trail grew from %d to %d on a refused fast-track", len(before.Audit), len(after.Audit)) |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | // A MEMBER probing fast-track meets the byte-identical 404 — the loud |
| 229 | // instant path past the window is the owner's alone. |
| 230 | func TestFastTrackMemberGets404(t *testing.T) { |
| 231 | fx := newConsoleFixture(t) |
| 232 | if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| 233 | t.Fatal(err) |
| 234 | } |
| 235 | fx.setUser("sam@keymail.dev") |
| 236 | c := newBrowser(t) |
| 237 | signUp(t, fx, c, "code-sam-bake3") |
| 238 | csrf := sessionToken(t, fx, c) |
| 239 | status, _, memberBody := postForm(t, c, fx.u("/apps/kass/fasttrack"), |
| 240 | url.Values{"csrf": {csrf}, "channel": {"stable"}}) |
| 241 | if status != http.StatusNotFound { |
| 242 | t.Fatalf("member fast-tracking = %d, want 404", status) |
| 243 | } |
| 244 | if p := fx.channelVersion(t, "kass", "stable"); p.FastTrack { |
| 245 | t.Fatal("a member's post waived the bake window") |
| 246 | } |
| 247 | // Byte-identical, not merely same-status: the difference between "not |
| 248 | // allowed" and "no such account" is not information this hands out. |
| 249 | strangerBody := strangerRefusal(t, fx, "code-zoe-bake3", fx.u("/apps/kass/fasttrack"), |
| 250 | url.Values{"channel": {"stable"}}) |
| 251 | if memberBody != strangerBody { |
| 252 | t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody) |
| 253 | } |
| 254 | // http.NotFound's own, well-known output — pinned so a future change to |
| 255 | // either path is caught even if it stays self-consistent. |
| 256 | if memberBody != "404 page not found\n" { |
| 257 | t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", memberBody) |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | // The stable-promote prompt (spec §7): enable_bake rides the same |
| 262 | // passkey-confirmed promote, and flips the flag on only when it is off. |
| 263 | func TestPromoteEnableBakeFlipsTheFlag(t *testing.T) { |
| 264 | fx := newConsoleFixture(t) |
| 265 | shipVersion(t, fx, "kass", "v3") |
| 266 | seedChannel(t, fx, "kass", "beta", "v3") |
| 267 | c := signInPaul(t, fx) |
| 268 | csrf := sessionToken(t, fx, c) |
| 269 | |
| 270 | postGated(t, fx, c, fx.u("/apps/kass/promote"), |
| 271 | url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}, "enable_bake": {"1"}, "confirmed": {"1"}}) |
| 272 | if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v3" { |
| 273 | t.Fatalf("stable = %s, want v3", p.Version) |
| 274 | } |
| 275 | if f := productionFlag(t, fx, "kass"); f == nil || !f.Production { |
| 276 | t.Fatalf("flag = %+v after enable_bake promote, want production on", f) |
| 277 | } |
| 278 | // The flash tells the truth about both halves of what just happened: the |
| 279 | // flag went on in this action, so the safety-delay note is now accurate. |
| 280 | if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Safety delay enabled.") || |
| 281 | !strings.Contains(body, "It goes live everywhere after the safety delay") { |
| 282 | t.Fatalf("enable_bake flash missing its two halves:\n%s", body) |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | // A checkbox that could not be honored SAYS so. When the flag will not read, |
| 287 | // the flip is never attempted — and the promote flash used to carry no trace |
| 288 | // of that, leaving the human who ticked the box believing the app now bakes. |
| 289 | func TestPromoteEnableBakeSaysWhenItCouldNotRead(t *testing.T) { |
| 290 | fx := newConsoleFixture(t) |
| 291 | shipVersion(t, fx, "kass", "v3") |
| 292 | seedChannel(t, fx, "kass", "beta", "v3") |
| 293 | c := signInPaul(t, fx) |
| 294 | csrf := sessionToken(t, fx, c) |
| 295 | if _, err := fx.rel.Put(context.Background(), |
| 296 | release.ProductionKey(release.OpsAccount, "kass"), []byte("{not a wrapper"), ""); err != nil { |
| 297 | t.Fatal(err) |
| 298 | } |
| 299 | |
| 300 | postGated(t, fx, c, fx.u("/apps/kass/promote"), |
| 301 | url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}, "enable_bake": {"1"}, "confirmed": {"1"}}) |
| 302 | if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v3" { |
| 303 | t.Fatalf("stable = %s, want v3 — the promote itself must not depend on the flag", p.Version) |
| 304 | } |
| 305 | _, _, body := get(t, c, fx.u("/apps/kass")) |
| 306 | if !strings.Contains(body, "could not be read, so the safety delay was not enabled") { |
| 307 | t.Fatalf("the flash is silent about the unhonored checkbox:\n%s", body) |
| 308 | } |
| 309 | if strings.Contains(body, "Safety delay enabled.") { |
| 310 | t.Fatal("the flash claims the safety delay was enabled over an unreadable flag") |
| 311 | } |
| 312 | } |
| 313 | |
| 314 | // The bake note is a claim about this app, not about the channel: a |
| 315 | // non-production app adopts a held channel immediately (spec §2), so |
| 316 | // promising a window would be a lie. |
| 317 | func TestPromoteBakeNoteFollowsTheFlag(t *testing.T) { |
| 318 | fx := newConsoleFixture(t) |
| 319 | shipVersion(t, fx, "kass", "v3") |
| 320 | c := signInPaul(t, fx) |
| 321 | csrf := sessionToken(t, fx, c) |
| 322 | |
| 323 | // No flag at all: beta holds, but this app does not bake. (edge first — |
| 324 | // the ladder promotes a build one rung at a time.) |
| 325 | seedChannel(t, fx, "kass", "edge", "v3") |
| 326 | // Checked, not fired and forgotten: a refused promote would make the |
| 327 | // absence assertion below true for the wrong reason. |
| 328 | if status, loc, body := postForm(t, c, fx.u("/apps/kass/promote"), |
| 329 | url.Values{"csrf": {csrf}, "version": {"v3"}, "channel": {"beta"}, "confirmed": {"1"}}); status != http.StatusSeeOther || |
| 330 | loc != fx.path("/apps/kass") { |
| 331 | t.Fatalf("beta promote = %d -> %q:\n%s", status, loc, body) |
| 332 | } |
| 333 | if p := fx.channelVersion(t, "kass", "beta"); p.Version != "v3" { |
| 334 | t.Fatalf("beta = %+v, want v3", p) |
| 335 | } |
| 336 | if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "It goes live everywhere after the safety delay") { |
| 337 | t.Fatalf("a flagless app was promised a safety delay:\n%s", body) |
| 338 | } |
| 339 | |
| 340 | // Flag on: the same rung now says so. |
| 341 | if err := release.SetProduction(context.Background(), fx.rel, fx.priv, |
| 342 | release.OpsAccount, "kass", true, "test"); err != nil { |
| 343 | t.Fatal(err) |
| 344 | } |
| 345 | shipVersion(t, fx, "kass", "v4") |
| 346 | seedChannel(t, fx, "kass", "edge", "v4") |
| 347 | if status, _, body := postForm(t, c, fx.u("/apps/kass/promote"), |
| 348 | url.Values{"csrf": {csrf}, "version": {"v4"}, "channel": {"beta"}, "confirmed": {"1"}}); status != http.StatusSeeOther { |
| 349 | t.Fatalf("beta promote of v4 = %d:\n%s", status, body) |
| 350 | } |
| 351 | if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "It goes live everywhere after the safety delay") { |
| 352 | t.Fatalf("a production app's held promotion did not mention the window:\n%s", body) |
| 353 | } |
| 354 | } |
| 355 | |