| 1 | package console |
| 2 | |
| 3 | // The app page's ladder section (pipelines spec §3, §5, §7). Four properties, |
| 4 | // in the order they would hurt if lost: the four rungs say what is on them, |
| 5 | // the promote control is owner-only, `stable` cannot be reached without a |
| 6 | // passkey, and a skip up the ladder is refused however it is posted. |
| 7 | |
| 8 | import ( |
| 9 | "bytes" |
| 10 | "context" |
| 11 | "crypto/ed25519" |
| 12 | "encoding/base64" |
| 13 | "encoding/json" |
| 14 | "net/http" |
| 15 | "net/url" |
| 16 | "strings" |
| 17 | "testing" |
| 18 | "time" |
| 19 | |
| 20 | "github.com/carlosframework/platform/internal/registry" |
| 21 | "github.com/carlosframework/platform/internal/release" |
| 22 | ) |
| 23 | |
| 24 | // shipVersion adds one more shipped release to the fixture app — fixture |
| 25 | // state, built through the real primitive so the console reads it exactly as |
| 26 | // it reads a real one. |
| 27 | func shipVersion(t *testing.T, fx *consoleFixture, app, version string) { |
| 28 | t.Helper() |
| 29 | m := release.Manifest{ |
| 30 | Schema: 1, App: app, Version: version, Kind: "binary", |
| 31 | BuiltAt: "2026-07-03T10:00:00Z", |
| 32 | Artifacts: map[string]release.Artifact{"linux-amd64": {File: app}}, |
| 33 | } |
| 34 | if err := release.Ship(context.Background(), fx.rel, release.OpsAccount, m, |
| 35 | map[string][]byte{"linux-amd64": []byte("binary " + version)}); err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | // seedChannel points a channel at a version through the UNCHECKED primitive. |
| 41 | // That is deliberate: these are arrangements of the world the console then has |
| 42 | // to read and gate, not promotions under test. |
| 43 | func seedChannel(t *testing.T, fx *consoleFixture, app, channel, version string, rec ...release.Promotion) { |
| 44 | t.Helper() |
| 45 | if err := release.Promote(context.Background(), fx.rel, fx.priv, |
| 46 | release.OpsAccount, app, channel, version, rec...); err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | // rungBlock cuts one rung's markup out of the rendered page, so "the beta rung |
| 52 | // shows v4" is an assertion about the beta rung and not about the page |
| 53 | // containing "v4" somewhere. |
| 54 | func rungBlock(t *testing.T, body, rung string) string { |
| 55 | t.Helper() |
| 56 | _, after, ok := strings.Cut(body, `id="rung-`+rung+`"`) |
| 57 | if !ok { |
| 58 | t.Fatalf("no %s rung in the ladder:\n%s", rung, body) |
| 59 | } |
| 60 | if before, _, ok := strings.Cut(after, `id="rung-`); ok { |
| 61 | return before |
| 62 | } |
| 63 | before, _, _ := strings.Cut(after, "</ol>") |
| 64 | return before |
| 65 | } |
| 66 | |
| 67 | // routeLine narrows further, to the one line a host is listed on. |
| 68 | func routeLine(t *testing.T, block, host string) string { |
| 69 | t.Helper() |
| 70 | for _, li := range strings.Split(block, "<li") { |
| 71 | if strings.Contains(li, ">"+host+"</a>") { |
| 72 | return li |
| 73 | } |
| 74 | } |
| 75 | t.Fatalf("host %s is not listed in:\n%s", host, block) |
| 76 | return "" |
| 77 | } |
| 78 | |
| 79 | // The four rungs, in order, each saying what it holds, when it got there and |
| 80 | // who put it there. The audit trail is new, so most live pointers have none: |
| 81 | // the rung that has one names the actor, and the rung that does not still |
| 82 | // renders — saying so — rather than crashing or inventing a name. |
| 83 | func TestLadderRendersFourRungs(t *testing.T) { |
| 84 | fx := newConsoleFixture(t) |
| 85 | shipVersion(t, fx, "kass", "v3") |
| 86 | shipVersion(t, fx, "kass", "v4") |
| 87 | seedChannel(t, fx, "kass", "edge", "v3", |
| 88 | release.Promotion{Version: "v3", To: "edge", Actor: "ci@keymail.dev"}) |
| 89 | seedChannel(t, fx, "kass", "beta", "v4", |
| 90 | release.Promotion{Version: "v4", From: "edge", To: "beta", Actor: "kim@keymail.dev"}) |
| 91 | |
| 92 | c := signInPaul(t, fx) |
| 93 | status, _, body := get(t, c, fx.u("/apps/kass/releases")) |
| 94 | if status != http.StatusOK { |
| 95 | t.Fatalf("app page = %d", status) |
| 96 | } |
| 97 | // Fixture: stable v1 and canary/x v2, both written before the audit trail |
| 98 | // existed; edge and beta seeded above. |
| 99 | for rung, version := range map[string]string{ |
| 100 | "canary": "v2", "edge": "v3", "beta": "v4", "stable": "v1", |
| 101 | } { |
| 102 | block := rungBlock(t, body, rung) |
| 103 | if !strings.Contains(block, ">"+version+"<") { |
| 104 | t.Errorf("the %s rung does not show %s:\n%s", rung, version, block) |
| 105 | } |
| 106 | } |
| 107 | if b := rungBlock(t, body, "beta"); !strings.Contains(b, "kim@keymail.dev") { |
| 108 | t.Errorf("the beta rung does not say who promoted it:\n%s", b) |
| 109 | } |
| 110 | if b := rungBlock(t, body, "edge"); !strings.Contains(b, "ci@keymail.dev") { |
| 111 | t.Errorf("the edge rung does not say who promoted it:\n%s", b) |
| 112 | } |
| 113 | // The pre-audit pointer: rendered, and honest about what it cannot say — |
| 114 | // the template now omits the "by ..." clause entirely rather than |
| 115 | // attributing the promotion to anybody, so the dishonest alternative |
| 116 | // (inventing an actor) is what must not appear. |
| 117 | if b := rungBlock(t, body, "stable"); strings.Contains(b, "ago by") { |
| 118 | t.Errorf("a pointer with no audit trail invented an actor:\n%s", b) |
| 119 | } |
| 120 | // Routes sit under the rung they subscribe to. |
| 121 | if b := rungBlock(t, body, "stable"); !strings.Contains(b, "kass.carlosapp.dev") { |
| 122 | t.Errorf("the stable rung does not list the hosts following it:\n%s", b) |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | // The promote control is the owner's. A member still sees the whole ladder — |
| 127 | // visibility is not the thing being restricted — and is offered no button. |
| 128 | func TestLadderPromoteControlIsOwnerOnly(t *testing.T) { |
| 129 | fx := newConsoleFixture(t) |
| 130 | shipVersion(t, fx, "kass", "v3") |
| 131 | seedChannel(t, fx, "kass", "edge", "v3") |
| 132 | |
| 133 | c := signInPaul(t, fx) |
| 134 | _, _, body := get(t, c, fx.u("/apps/kass/releases")) |
| 135 | if !strings.Contains(body, "Promote v3 to beta") { |
| 136 | t.Fatalf("owner is offered no edge -> beta control:\n%s", body) |
| 137 | } |
| 138 | // The control posts to the app's one promote endpoint — the same door, |
| 139 | // with the same ladder and step-up gates behind it. |
| 140 | if !strings.Contains(body, `action="/a/bab/apps/kass/promote"`) { |
| 141 | t.Fatal("the ladder control does not post to the promote endpoint") |
| 142 | } |
| 143 | |
| 144 | if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| 145 | t.Fatal(err) |
| 146 | } |
| 147 | fx.setUser("sam@keymail.dev") |
| 148 | cm := newBrowser(t) |
| 149 | signUp(t, fx, cm, "code-sam-ladder") |
| 150 | status, _, mbody := get(t, cm, fx.u("/apps/kass/releases")) |
| 151 | if status != http.StatusOK { |
| 152 | t.Fatalf("member app page = %d, want 200", status) |
| 153 | } |
| 154 | if !strings.Contains(mbody, `id="rung-stable"`) { |
| 155 | t.Fatal("a member cannot see the ladder at all") |
| 156 | } |
| 157 | if strings.Contains(mbody, "Promote v3 to beta") { |
| 158 | t.Error("a member is offered the ladder's promote control") |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | // Stable is the rung that puts a build in front of every user, so a live |
| 163 | // session is not enough on its own. Without the passkey the pointer does not |
| 164 | // move; with it, the held promotion resumes and is recorded. |
| 165 | func TestLadderPromoteToStableNeedsStepUp(t *testing.T) { |
| 166 | fx := newConsoleFixture(t) |
| 167 | shipVersion(t, fx, "kass", "v3") |
| 168 | seedChannel(t, fx, "kass", "beta", "v3") |
| 169 | c := signInPaul(t, fx) |
| 170 | csrf := sessionToken(t, fx, c) |
| 171 | |
| 172 | status, loc, _ := postForm(t, c, fx.u("/apps/kass/promote"), |
| 173 | url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}, "confirmed": {"1"}}) |
| 174 | if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 175 | t.Fatalf("un-stepped-up stable promotion = %d -> %q, want a redirect into Keymail", status, loc) |
| 176 | } |
| 177 | if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v1" { |
| 178 | t.Fatalf("stable moved to %s with no passkey confirmation", p.Version) |
| 179 | } |
| 180 | |
| 181 | if status, _, body := completeStepUp(t, fx, c, loc); status != http.StatusSeeOther { |
| 182 | t.Fatalf("resumed promotion = %d:\n%s", status, body) |
| 183 | } |
| 184 | p := fx.channelVersion(t, "kass", "stable") |
| 185 | if p.Version != "v3" { |
| 186 | t.Fatalf("stable = %s after a confirmed promotion, want v3", p.Version) |
| 187 | } |
| 188 | if len(p.Audit) == 0 { |
| 189 | t.Fatal("the promotion recorded nothing on the pointer it moved") |
| 190 | } |
| 191 | if a := p.Audit[0]; a.Version != "v3" || a.From != "beta" || a.To != "stable" || |
| 192 | a.Actor != "paul@keymail.dev" || a.Hotfix { |
| 193 | t.Fatalf("audit[0] = %+v, want v3 beta->stable by paul, no hotfix", a) |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | // A skip is refused whoever asks and however confirmed: edge -> stable never |
| 198 | // happens through the console, because "it was signed off on beta" is only |
| 199 | // worth anything if everything on stable came through beta. |
| 200 | func TestLadderRefusesASkip(t *testing.T) { |
| 201 | fx := newConsoleFixture(t) |
| 202 | shipVersion(t, fx, "kass", "v3") |
| 203 | seedChannel(t, fx, "kass", "edge", "v3") |
| 204 | c := signInPaul(t, fx) |
| 205 | csrf := sessionToken(t, fx, c) |
| 206 | |
| 207 | // postGated walks the passkey round trip, so this is the refusal of a |
| 208 | // FULLY confirmed owner — the ladder, not the gate, is what stops it. |
| 209 | postGated(t, fx, c, fx.u("/apps/kass/promote"), |
| 210 | url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}, "confirmed": {"1"}}) |
| 211 | if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v1" { |
| 212 | t.Fatalf("a skip from edge moved stable to %s", p.Version) |
| 213 | } |
| 214 | if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "stable is entered from beta only") { |
| 215 | t.Fatalf("no ladder refusal flash:\n%s", body) |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | // A canary is badged; a canary that binds the SAME ENVIRONMENT as the app's |
| 220 | // stable route is badged unmissably, because that is an unreviewed build |
| 221 | // wired to production glue (spec §4a, §5) — whichever host it happens to be |
| 222 | // on. A canary bound to a genuinely different environment is the ordinary, |
| 223 | // unremarkable case. Both badges are chrome, so both are icons, not glyphs. |
| 224 | func TestProdWiredCanaryIsBadgedDistinctly(t *testing.T) { |
| 225 | fx := newConsoleFixture(t) |
| 226 | // The fixture's own kass.carlosapp.dev is already on stable, unbound |
| 227 | // (Environment ""). A canary that is ALSO unbound binds the same |
| 228 | // environment as stable ("default" both times) and is prod-wired; one |
| 229 | // bound to a real, different environment is not — regardless of which |
| 230 | // host either is on. |
| 231 | fx.addRoute(t, registry.Route{Host: "rc.kass.carlosapp.dev", Kind: "instance", Status: "ready", |
| 232 | Version: "v2", App: "kass", Channel: "canary/x", Addr: "127.0.0.1:9002"}) |
| 233 | fx.addRoute(t, registry.Route{Host: "side.kass.carlosapp.dev", Kind: "instance", Status: "ready", |
| 234 | Version: "v2", App: "kass", Channel: "canary/x", Environment: "sandbox", Addr: "127.0.0.1:9003"}) |
| 235 | c := signInPaul(t, fx) |
| 236 | _, _, body := get(t, c, fx.u("/apps/kass/releases")) |
| 237 | block := rungBlock(t, body, "canary") |
| 238 | |
| 239 | sameEnv := routeLine(t, block, "rc.kass.carlosapp.dev") |
| 240 | if !strings.Contains(sameEnv, "chip-prod") { |
| 241 | t.Errorf("a canary binding the same (default) environment as stable is not flagged:\n%s", sameEnv) |
| 242 | } |
| 243 | diffEnv := routeLine(t, block, "side.kass.carlosapp.dev") |
| 244 | if !strings.Contains(diffEnv, "chip-canary") { |
| 245 | t.Errorf("a canary route carries no canary badge:\n%s", diffEnv) |
| 246 | } |
| 247 | if strings.Contains(diffEnv, "chip-prod") { |
| 248 | t.Errorf("a canary bound to a DIFFERENT environment than stable is flagged as production-wired:\n%s", diffEnv) |
| 249 | } |
| 250 | if !strings.Contains(sameEnv, "<svg") || !strings.Contains(diffEnv, "<svg") { |
| 251 | t.Error("the badges are text glyphs, not icons") |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | // A canary on the app's APEX host serves everybody, whatever it binds — so |
| 256 | // it stays flagged even when its environment differs from stable's (review |
| 257 | // IMPORTANT 5). The environment set-membership rule is the honest definition |
| 258 | // of "wired to production glue"; the apex signal is the honest definition of |
| 259 | // "in front of production traffic". Losing the second one when the first |
| 260 | // landed would have shipped a silent reduction in warning coverage. |
| 261 | func TestProdWiredCanaryOnTheApexHostIsFlaggedWhateverItBinds(t *testing.T) { |
| 262 | fx := newConsoleFixture(t) |
| 263 | reg, err := registry.Open(fx.regPath) |
| 264 | if err != nil { |
| 265 | t.Fatal(err) |
| 266 | } |
| 267 | // Put a canary on the apex host, bound to an environment nothing on |
| 268 | // stable binds; docs.kass.carlosapp.dev stays as the app's stable route. |
| 269 | if err := reg.Delete("kass.carlosapp.dev"); err != nil { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | fx.addRoute(t, registry.Route{Host: "kass.carlosapp.dev", Kind: "instance", Status: "ready", |
| 273 | Version: "v2", App: "kass", Channel: "canary/x", Environment: "sandbox", Addr: "127.0.0.1:9007"}) |
| 274 | |
| 275 | c := signInPaul(t, fx) |
| 276 | _, _, body := get(t, c, fx.u("/apps/kass/releases")) |
| 277 | block := rungBlock(t, body, "canary") |
| 278 | line := routeLine(t, block, "kass.carlosapp.dev") |
| 279 | if !strings.Contains(line, "chip-prod") { |
| 280 | t.Errorf("a canary on the apex host is not flagged prod-wired:\n%s", line) |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | // No stable route at all means no production environment to be wired in |
| 285 | // front of — a canary is never flagged by the ENVIRONMENT signal in that |
| 286 | // case, whatever it binds. |
| 287 | func TestProdWiredCanaryNeedsAStableRouteToCompareAgainst(t *testing.T) { |
| 288 | fx := newConsoleFixture(t) |
| 289 | reg, err := registry.Open(fx.regPath) |
| 290 | if err != nil { |
| 291 | t.Fatal(err) |
| 292 | } |
| 293 | // Remove the fixture's two stable-channel kass routes so the app has no |
| 294 | // stable route left to compare a canary against. |
| 295 | if err := reg.Delete("kass.carlosapp.dev"); err != nil { |
| 296 | t.Fatal(err) |
| 297 | } |
| 298 | if err := reg.Delete("docs.kass.carlosapp.dev"); err != nil { |
| 299 | t.Fatal(err) |
| 300 | } |
| 301 | // A non-canary route holds the apex, so the OTHER prod-wired signal (the |
| 302 | // apex-host one, review IMPORTANT 5) cannot fire and this test stays |
| 303 | // about the environment rule alone. |
| 304 | fx.addRoute(t, registry.Route{Host: "kass.carlosapp.dev", Kind: "instance", Status: "ready", |
| 305 | Version: "v2", App: "kass", Channel: "beta", Addr: "127.0.0.1:9005"}) |
| 306 | fx.addRoute(t, registry.Route{Host: "rc.kass.carlosapp.dev", Kind: "instance", Status: "ready", |
| 307 | Version: "v2", App: "kass", Channel: "canary/x", Addr: "127.0.0.1:9004"}) |
| 308 | |
| 309 | c := signInPaul(t, fx) |
| 310 | _, _, body := get(t, c, fx.u("/apps/kass/releases")) |
| 311 | block := rungBlock(t, body, "canary") |
| 312 | line := routeLine(t, block, "rc.kass.carlosapp.dev") |
| 313 | if strings.Contains(line, "chip-prod") { |
| 314 | t.Errorf("a canary is flagged prod-wired on an app with no stable route:\n%s", line) |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | // Every route on the page shows the environment it is bound to, beside the |
| 319 | // channel it is listed under — "default" for an unbound route, the bundle |
| 320 | // name for a bound one (spec §4a, plan Task 5). |
| 321 | func TestLadderShowsEachRoutesEnvironment(t *testing.T) { |
| 322 | fx := newConsoleFixture(t) |
| 323 | fx.addRoute(t, registry.Route{Host: "eu.kass.carlosapp.dev", Kind: "instance", Status: "ready", |
| 324 | Version: "v1", App: "kass", Channel: "stable", Environment: "eu-prod", Addr: "127.0.0.1:9011"}) |
| 325 | |
| 326 | c := signInPaul(t, fx) |
| 327 | _, _, body := get(t, c, fx.u("/apps/kass/releases")) |
| 328 | block := rungBlock(t, body, "stable") |
| 329 | |
| 330 | unbound := routeLine(t, block, "kass.carlosapp.dev") |
| 331 | if !strings.Contains(unbound, "chip-env") || !strings.Contains(unbound, "default") { |
| 332 | t.Errorf("an unbound route does not show \"default\" as its environment:\n%s", unbound) |
| 333 | } |
| 334 | bound := routeLine(t, block, "eu.kass.carlosapp.dev") |
| 335 | if !strings.Contains(bound, "chip-env") || !strings.Contains(bound, "eu-prod") { |
| 336 | t.Errorf("a bound route does not show its environment name:\n%s", bound) |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | // The optional read-only capabilities render (app-capabilities design, plan |
| 341 | // Task 9 step 3): a route's operator-granted tokens appear beside its other |
| 342 | // per-route facts, human-legible ("host-role, port:25" rather than the |
| 343 | // registry column's "host-role,port:25"), and a route with nothing granted |
| 344 | // shows no "granted:" text at all — the empty case is silence, not an empty |
| 345 | // chip. Read-only: this is a render of what `carlos route -grant` already |
| 346 | // wrote, nothing here writes it. |
| 347 | func TestLadderShowsGrantedCapabilities(t *testing.T) { |
| 348 | fx := newConsoleFixture(t) |
| 349 | fx.addRoute(t, registry.Route{Host: "mx.kass.carlosapp.dev", Kind: "instance", Status: "ready", |
| 350 | Version: "v1", App: "kass", Channel: "stable", Caps: "host-role,port:25", Addr: "127.0.0.1:9013"}) |
| 351 | |
| 352 | c := signInPaul(t, fx) |
| 353 | _, _, body := get(t, c, fx.u("/apps/kass/releases")) |
| 354 | block := rungBlock(t, body, "stable") |
| 355 | |
| 356 | granted := routeLine(t, block, "mx.kass.carlosapp.dev") |
| 357 | if !strings.Contains(granted, "granted: host-role, port:25") { |
| 358 | t.Errorf("a route with granted capabilities does not render them:\n%s", granted) |
| 359 | } |
| 360 | ungranted := routeLine(t, block, "kass.carlosapp.dev") |
| 361 | if strings.Contains(ungranted, "granted:") { |
| 362 | t.Errorf("a route with nothing granted rendered a granted: line anyway:\n%s", ungranted) |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | // TestLadderGrantedCapabilitiesNeverCrossAccounts is the scoped guard from the |
| 367 | // 2026-08-03 review (I4). Assemble groups routes by app NAME with no account |
| 368 | // filter, and app names are unique per ACCOUNT rather than globally |
| 369 | // (Store.AppAccounts) — so two accounts each owning an app called "kass" both |
| 370 | // land in one AppView, and every per-route field rides that bleed. Grants are |
| 371 | // the sharpest thing to ride it: "granted: port:587, host-role" says which |
| 372 | // privileged port and which slice of the box's own authority an operator has |
| 373 | // handed ANOTHER tenant's route. |
| 374 | // |
| 375 | // The rest of the bleed (the row itself, its environment chip, its prod-wired |
| 376 | // badge) is pre-existing and stays until Assemble takes a real account filter. |
| 377 | // This pins the one field that was never allowed to cross: A's page renders |
| 378 | // its OWN grants and carries nothing of B's, by any spelling. |
| 379 | func TestLadderGrantedCapabilitiesNeverCrossAccounts(t *testing.T) { |
| 380 | fx := newConsoleFixture(t) |
| 381 | if err := fx.st.CreateAccount(42, "cdef", "Rival", "rival@keymail.dev", time.Now()); err != nil { |
| 382 | t.Fatal(err) |
| 383 | } |
| 384 | if _, err := fx.st.CreateAppIn(42, "kass", "rival@keymail.dev", time.Now()); err != nil { |
| 385 | t.Fatal(err) |
| 386 | } |
| 387 | // paul's own route, with a grant of its own — the guard must not silence |
| 388 | // the render it was added for. |
| 389 | fx.addRoute(t, registry.Route{Host: "mx.kass.carlosapp.dev", Kind: "instance", Status: "ready", |
| 390 | Version: "v1", App: "kass", Channel: "stable", Caps: "port:25", Addr: "127.0.0.1:9014"}) |
| 391 | // Same app name, another account, paul is not a member of it. Distinct |
| 392 | // tokens, so a leak cannot hide behind paul's own. |
| 393 | fx.addRoute(t, registry.Route{Host: "rival.kass.example.com", Kind: "instance", Status: "ready", |
| 394 | Version: "v1", App: "kass", Channel: "stable", Account: "cdef", |
| 395 | Caps: "host-role,port:587", Addr: "127.0.0.1:9015"}) |
| 396 | |
| 397 | c := signInPaul(t, fx) |
| 398 | _, _, body := get(t, c, fx.u("/apps/kass/releases")) |
| 399 | |
| 400 | block := rungBlock(t, body, "stable") |
| 401 | own := routeLine(t, block, "mx.kass.carlosapp.dev") |
| 402 | if !strings.Contains(own, "granted: port:25") { |
| 403 | t.Errorf("the viewing account's OWN grants stopped rendering:\n%s", own) |
| 404 | } |
| 405 | // Anywhere on the page, not just in the rival's own row: the tokens |
| 406 | // themselves are the secret, and where they are printed does not change |
| 407 | // that. (host-role is deliberately not asserted on — paul's fixture is |
| 408 | // free to grant it too; the port is the unambiguous sentinel.) |
| 409 | if strings.Contains(body, "port:587") { |
| 410 | t.Fatalf("the app page leaked another account's grant:\n%s", block) |
| 411 | } |
| 412 | // The rival's row itself is still expected on the page (the pre-existing |
| 413 | // grouping bleed above); it must simply carry no granted: line. Tolerant of |
| 414 | // the row being gone entirely, which is what the follow-up account filter |
| 415 | // will do — that is also no leak. |
| 416 | for _, li := range strings.Split(block, "<li") { |
| 417 | if strings.Contains(li, ">rival.kass.example.com</a>") && strings.Contains(li, "granted:") { |
| 418 | t.Fatalf("another account's route rendered a granted: line:\n%s", li) |
| 419 | } |
| 420 | } |
| 421 | } |
| 422 | |
| 423 | // Visibility, not permission (spec §5): a member sees the same environment |
| 424 | // and prod-wired chrome an owner does. Only the promote control is |
| 425 | // owner-gated — see TestLadderPromoteControlIsOwnerOnly. |
| 426 | func TestLadderEnvironmentAndProdWiredVisibleToNonOwner(t *testing.T) { |
| 427 | fx := newConsoleFixture(t) |
| 428 | fx.addRoute(t, registry.Route{Host: "rc.kass.carlosapp.dev", Kind: "instance", Status: "ready", |
| 429 | Version: "v2", App: "kass", Channel: "canary/x", Addr: "127.0.0.1:9002"}) |
| 430 | if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| 431 | t.Fatal(err) |
| 432 | } |
| 433 | fx.setUser("sam@keymail.dev") |
| 434 | cm := newBrowser(t) |
| 435 | signUp(t, fx, cm, "code-sam-envs") |
| 436 | status, _, body := get(t, cm, fx.u("/apps/kass/releases")) |
| 437 | if status != http.StatusOK { |
| 438 | t.Fatalf("member app page = %d, want 200", status) |
| 439 | } |
| 440 | canaryBlock := rungBlock(t, body, "canary") |
| 441 | line := routeLine(t, canaryBlock, "rc.kass.carlosapp.dev") |
| 442 | if !strings.Contains(line, "chip-prod") { |
| 443 | t.Errorf("a member does not see the prod-wired badge:\n%s", line) |
| 444 | } |
| 445 | stableBlock := rungBlock(t, body, "stable") |
| 446 | if !strings.Contains(stableBlock, "chip-env") { |
| 447 | t.Errorf("a member does not see route environment chrome:\n%s", stableBlock) |
| 448 | } |
| 449 | } |
| 450 | |
| 451 | // ---- Canary TTL (spec §5 "Canaries expire") -------------------------------- |
| 452 | |
| 453 | // A canary promoted moments ago — exactly what every real promotion through |
| 454 | // this console produces, Promote always stamping "now" — must carry no |
| 455 | // expired badge. This is the case AGENTS.md's own console route is in on |
| 456 | // every deploy (canary/console, re-promoted each time): a false positive |
| 457 | // here would badge the console's own production traffic as neglected. |
| 458 | func TestLadderFreshCanaryCarriesNoExpiredBadge(t *testing.T) { |
| 459 | fx := newConsoleFixture(t) |
| 460 | fx.addRoute(t, registry.Route{Host: "rc.kass.carlosapp.dev", Kind: "instance", Status: "ready", |
| 461 | Version: "v2", App: "kass", Channel: "canary/x", Addr: "127.0.0.1:9002"}) |
| 462 | seedChannel(t, fx, "kass", "canary/x", "v2") // stamps PromotedAt = now |
| 463 | |
| 464 | c := signInPaul(t, fx) |
| 465 | _, _, body := get(t, c, fx.u("/apps/kass/releases")) |
| 466 | block := rungBlock(t, body, "canary") |
| 467 | line := routeLine(t, block, "rc.kass.carlosapp.dev") |
| 468 | |
| 469 | if strings.Contains(line, "chip-expired") { |
| 470 | t.Errorf("a freshly-promoted canary is badged expired:\n%s", line) |
| 471 | } |
| 472 | // This route binds the same (default) environment as the fixture's |
| 473 | // stable route, so it is ALSO prod-wired (TestProdWiredCanaryIsBadgedDistinctly |
| 474 | // covers that badge on its own) — chip-prod or chip-canary, either is the |
| 475 | // "this is a canary" badge the expired flag would sit beside; the point |
| 476 | // of this test is only that neither carries chip-expired. |
| 477 | if !strings.Contains(line, "chip-prod") && !strings.Contains(line, "chip-canary") { |
| 478 | t.Errorf("the fresh canary lost its canary-family badge entirely:\n%s", line) |
| 479 | } |
| 480 | } |
| 481 | |
| 482 | // assembleLadder is exercised directly here (no store, no signing) because |
| 483 | // there is no way to get a genuinely aged, verifiably-signed pointer through |
| 484 | // the public Promote path — it always stamps the real clock, deliberately |
| 485 | // (release.TestSignStampsPromotedAt pins that). Constructing the ChannelView |
| 486 | // by hand is the same input assembleChannels would have produced from a |
| 487 | // pointer that really was promoted eight days ago. |
| 488 | func TestLadderExpiredCanaryIsBadgedWithAge(t *testing.T) { |
| 489 | now := time.Now() |
| 490 | old := now.Add(-(release.CanaryTTL + 8*24*time.Hour)) // 8 days past the TTL |
| 491 | channels := []ChannelView{{Name: "canary/x", Version: "v9", PromotedAt: old}} |
| 492 | // A stable route on a DIFFERENT, non-apex, non-matching-environment host |
| 493 | // keeps this canary out of BOTH prod-wired signals (prodWiredCanaryHosts |
| 494 | // is exercised on its own elsewhere) — this test is about the expired |
| 495 | // badge specifically, so it stays clear of the prod-wired one. |
| 496 | routes := []RouteView{ |
| 497 | {Host: "a.example.com", Channel: "stable", Environment: "prod-env"}, |
| 498 | {Host: "rc.example.com", Channel: "canary/x"}, |
| 499 | } |
| 500 | |
| 501 | rungs := assembleLadder(channels, routes, nil, now) |
| 502 | var canary *LadderRung |
| 503 | for i := range rungs { |
| 504 | if rungs[i].Rung == release.RungCanary { |
| 505 | canary = &rungs[i] |
| 506 | } |
| 507 | } |
| 508 | if canary == nil || len(canary.Channels) != 1 || len(canary.Channels[0].Routes) != 1 { |
| 509 | t.Fatalf("canary rung = %+v, want one channel with one route", canary) |
| 510 | } |
| 511 | rt := canary.Channels[0].Routes[0] |
| 512 | if !rt.Expired { |
| 513 | t.Fatal("a canary promoted more than CanaryTTL ago must be marked Expired") |
| 514 | } |
| 515 | if rt.ExpiredFor != "8d" { |
| 516 | t.Errorf("ExpiredFor = %q, want \"8d\"", rt.ExpiredFor) |
| 517 | } |
| 518 | if !rt.Canary { |
| 519 | t.Error("an expired canary route must still carry the ordinary canary flag") |
| 520 | } |
| 521 | |
| 522 | // Render it through the real template — proves the badge actually |
| 523 | // reaches the page, with the icon and the age, beside the plain canary |
| 524 | // badge rather than instead of it. |
| 525 | p := page{Base: "/a/bab", Role: RoleOwner, CanPromote: true, |
| 526 | Detail: &AppView{App: "demo", Ladder: rungs}} |
| 527 | var buf bytes.Buffer |
| 528 | if err := templates.ExecuteTemplate(&buf, "ladder", p); err != nil { |
| 529 | t.Fatalf("render ladder: %v", err) |
| 530 | } |
| 531 | html := buf.String() |
| 532 | line := routeLine(t, rungBlock(t, html, "canary"), "rc.example.com") |
| 533 | if !strings.Contains(line, "chip-expired") { |
| 534 | t.Errorf("rendered route carries no chip-expired badge:\n%s", line) |
| 535 | } |
| 536 | if !strings.Contains(line, "8d") { |
| 537 | t.Errorf("rendered badge does not show the age:\n%s", line) |
| 538 | } |
| 539 | if !strings.Contains(line, "chip-canary") { |
| 540 | t.Errorf("the expired badge replaced the ordinary canary badge instead of sitting beside it:\n%s", line) |
| 541 | } |
| 542 | if !strings.Contains(line, "<svg") { |
| 543 | t.Error("the expired badge is a text glyph, not an icon") |
| 544 | } |
| 545 | } |
| 546 | |
| 547 | // A channel that has not yet reached CanaryTTL contributes nothing to |
| 548 | // canaryExpiry's map — the direct unit-level twin of the fixture-based fresh |
| 549 | // test above, pinned at the function boundary rather than through HTTP. |
| 550 | func TestCanaryExpiryOmitsUnexpiredChannels(t *testing.T) { |
| 551 | now := time.Now() |
| 552 | channels := []ChannelView{ |
| 553 | {Name: "canary/fresh", PromotedAt: now}, |
| 554 | {Name: "canary/old", PromotedAt: now.Add(-(release.CanaryTTL + time.Hour))}, |
| 555 | {Name: "stable", PromotedAt: now.Add(-365 * 24 * time.Hour)}, // no TTL at all |
| 556 | {Name: "canary/broken", PromotedAt: now.Add(-365 * 24 * time.Hour), Err: "unreadable"}, |
| 557 | } |
| 558 | got := canaryExpiry(channels, now) |
| 559 | if _, ok := got["canary/fresh"]; ok { |
| 560 | t.Error("a fresh canary must not appear in the expiry map") |
| 561 | } |
| 562 | if _, ok := got["stable"]; ok { |
| 563 | t.Error("stable carries no TTL and must never appear in the expiry map") |
| 564 | } |
| 565 | if _, ok := got["canary/broken"]; ok { |
| 566 | t.Error("a channel whose pointer errored must not be guessed at as expired") |
| 567 | } |
| 568 | if _, ok := got["canary/old"]; !ok { |
| 569 | t.Error("an old canary must appear in the expiry map") |
| 570 | } |
| 571 | } |
| 572 | |
| 573 | // seedExpiredCanary hand-signs a canary pointer whose lease ran out long |
| 574 | // ago — every public promotion path stamps the real clock, so forging |
| 575 | // PromotedAt in the wrapper is the only way to put an EXPIRED canary in |
| 576 | // front of a console under test (the same technique, for the same reason, |
| 577 | // as internal/release's writeBackdatedPointer, which is deliberately not |
| 578 | // exported). |
| 579 | func seedExpiredCanary(t *testing.T, fx *consoleFixture, app, channel, version string) { |
| 580 | t.Helper() |
| 581 | p := &release.ChannelPointer{Schema: 1, App: app, Channel: channel, |
| 582 | Version: version, PromotedAt: time.Now().Add(-(release.CanaryTTL + 30*24*time.Hour))} |
| 583 | payload, err := json.Marshal(p) |
| 584 | if err != nil { |
| 585 | t.Fatal(err) |
| 586 | } |
| 587 | b64 := base64.StdEncoding.EncodeToString(payload) |
| 588 | wrapper, err := json.Marshal(struct { |
| 589 | Payload string `json:"payload"` |
| 590 | Sig string `json:"sig"` |
| 591 | }{b64, base64.StdEncoding.EncodeToString(ed25519.Sign(fx.priv, []byte(b64)))}) |
| 592 | if err != nil { |
| 593 | t.Fatal(err) |
| 594 | } |
| 595 | if _, err := fx.rel.Put(context.Background(), |
| 596 | release.ChannelKey(release.OpsAccount, app, channel), wrapper, ""); err != nil { |
| 597 | t.Fatal(err) |
| 598 | } |
| 599 | } |
| 600 | |
| 601 | // TestLadderPromotesOntoAnExpiredCanary pins spec §5's visibility-only |
| 602 | // contract at the one surface the release-layer pins cannot reach: the |
| 603 | // console's promote handler. An expired canary is exactly where the next |
| 604 | // build lands when work on that branch resumes, and a "this canary expired" |
| 605 | // gate here — however well-meant a confirmation might look — would turn a |
| 606 | // guard rail into an obstacle at the moment it is least wanted. (2026-08-03 |
| 607 | // review, IMPORTANT 1: a mutation gating handlePromote on expiry failed |
| 608 | // zero tests before this one existed.) |
| 609 | func TestLadderPromotesOntoAnExpiredCanary(t *testing.T) { |
| 610 | fx := newConsoleFixture(t) |
| 611 | shipVersion(t, fx, "kass", "v8") |
| 612 | seedExpiredCanary(t, fx, "kass", "canary/old-branch", "v1") |
| 613 | c := signInPaul(t, fx) |
| 614 | csrf := sessionToken(t, fx, c) |
| 615 | |
| 616 | status, _, body := postForm(t, c, fx.u("/apps/kass/promote"), |
| 617 | url.Values{"csrf": {csrf}, "channel": {"canary/old-branch"}, "version": {"v8"}, "confirmed": {"1"}}) |
| 618 | if status != http.StatusSeeOther { |
| 619 | t.Fatalf("promotion onto an expired canary = %d, want 303:\n%s", status, body) |
| 620 | } |
| 621 | if p := fx.channelVersion(t, "kass", "canary/old-branch"); p.Version != "v8" { |
| 622 | t.Fatalf("expired canary still holds %s — expiry has become a gate", p.Version) |
| 623 | } |
| 624 | } |
| 625 | |
| 626 | // The bake status line and its controls (spec §7): off-by-default says so; |
| 627 | // an owner gets the turn-on/turn-off controls; a held channel row offers |
| 628 | // the owner fast-track; the stable promote form carries the enable_bake |
| 629 | // prompt while the flag is off. |
| 630 | func TestLadderShowsBakeStateAndControls(t *testing.T) { |
| 631 | fx := newConsoleFixture(t) |
| 632 | shipVersion(t, fx, "kass", "v3") |
| 633 | seedChannel(t, fx, "kass", "beta", "v3") |
| 634 | c := signInPaul(t, fx) |
| 635 | |
| 636 | csrf := sessionToken(t, fx, c) |
| 637 | _, _, body := get(t, c, fx.u("/apps/kass/releases")) |
| 638 | if !strings.Contains(body, "Safety delay") { |
| 639 | t.Fatalf("no safety-delay status line:\n%s", body) |
| 640 | } |
| 641 | if !strings.Contains(body, `action="/a/bab/apps/kass/production"`) { |
| 642 | t.Fatal("owner is offered no production toggle") |
| 643 | } |
| 644 | // Off by default, and the stable promote form asks the question — now on |
| 645 | // the confirm card the un-confirmed promote returns, not the ladder |
| 646 | // itself (the checkbox moved there; see confirm.go's confirmPromote). |
| 647 | _, _, confirmBody := postForm(t, c, fx.u("/apps/kass/promote"), |
| 648 | url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}}) |
| 649 | if !strings.Contains(confirmBody, `name="enable_bake"`) { |
| 650 | t.Fatal("the stable promote confirm card does not carry the enable_bake prompt") |
| 651 | } |
| 652 | |
| 653 | // Flip on; the held beta head now shows its window and the fast-track |
| 654 | // control appears on it. |
| 655 | if err := release.SetProduction(context.Background(), fx.rel, fx.priv, |
| 656 | release.OpsAccount, "kass", true, "test"); err != nil { |
| 657 | t.Fatal(err) |
| 658 | } |
| 659 | _, _, body = get(t, c, fx.u("/apps/kass/releases")) |
| 660 | if !strings.Contains(body, `action="/a/bab/apps/kass/fasttrack"`) { |
| 661 | t.Fatalf("no fast-track control on a held head:\n%s", rungBlock(t, body, "beta")) |
| 662 | } |
| 663 | if !strings.Contains(body, `name="production" value="off"`) { |
| 664 | t.Fatal("owner is offered no turn-off control once bake is on") |
| 665 | } |
| 666 | // The confirm card's checkbox no longer offers to enable the bake once |
| 667 | // it's already on. |
| 668 | _, _, confirmBody = postForm(t, c, fx.u("/apps/kass/promote"), |
| 669 | url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}}) |
| 670 | if strings.Contains(confirmBody, `name="enable_bake"`) { |
| 671 | t.Fatal("the enable_bake prompt survived the flag turning on") |
| 672 | } |
| 673 | |
| 674 | // Waive it; the badge is permanent and the control is gone. |
| 675 | if _, err := release.FastTrack(context.Background(), fx.rel, fx.priv, |
| 676 | release.OpsAccount, "kass", "beta", "test"); err != nil { |
| 677 | t.Fatal(err) |
| 678 | } |
| 679 | _, _, body = get(t, c, fx.u("/apps/kass/releases")) |
| 680 | b := rungBlock(t, body, "beta") |
| 681 | if !strings.Contains(b, "delay skipped") { |
| 682 | t.Errorf("no permanent delay-skipped badge:\n%s", b) |
| 683 | } |
| 684 | if strings.Contains(b, `action="/a/bab/apps/kass/fasttrack"`) { |
| 685 | t.Error("a waived head still offers the fast-track control") |
| 686 | } |
| 687 | } |
| 688 | |
| 689 | // A member sees state, never the owner-only controls (off / fast-track) — |
| 690 | // but IS offered turn-on, the safe direction. Off-state alone would make |
| 691 | // the turn-off/fast-track assertions tautological (neither control is |
| 692 | // eligible to render for ANYONE — owner included — when the flag is off |
| 693 | // and no channel is held): so this also flips the flag on and confirms |
| 694 | // through the OWNER's own browser, first, that the controls DO render in |
| 695 | // that state, before checking the member does not get them in the same |
| 696 | // state. That ordering is what keeps the member-side absence checks from |
| 697 | // passing vacuously. |
| 698 | func TestLadderBakeControlsMemberView(t *testing.T) { |
| 699 | fx := newConsoleFixture(t) |
| 700 | shipVersion(t, fx, "kass", "v9") |
| 701 | seedChannel(t, fx, "kass", "beta", "v9") |
| 702 | c := signInPaul(t, fx) |
| 703 | |
| 704 | if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| 705 | t.Fatal(err) |
| 706 | } |
| 707 | fx.setUser("sam@keymail.dev") |
| 708 | cm := newBrowser(t) |
| 709 | signUp(t, fx, cm, "code-sam-baketpl") |
| 710 | |
| 711 | _, _, mbody := get(t, cm, fx.u("/apps/kass/releases")) |
| 712 | if !strings.Contains(mbody, "Safety delay") { |
| 713 | t.Fatal("member sees no safety-delay status") |
| 714 | } |
| 715 | if !strings.Contains(mbody, `name="production" value="on"`) { |
| 716 | t.Error("member is not offered the turn-on control") |
| 717 | } |
| 718 | if strings.Contains(mbody, `name="production" value="off"`) { |
| 719 | t.Error("member is offered the owner-only turn-off control") |
| 720 | } |
| 721 | if strings.Contains(mbody, `/fasttrack"`) { |
| 722 | t.Error("member is offered the owner-only fast-track control") |
| 723 | } |
| 724 | |
| 725 | // Put the page into the state where turn-off and fast-track are |
| 726 | // ELIGIBLE to render: flag on, with a held beta head. Confirm the |
| 727 | // OWNER gets both controls in this state... |
| 728 | if err := release.SetProduction(context.Background(), fx.rel, fx.priv, |
| 729 | release.OpsAccount, "kass", true, "test"); err != nil { |
| 730 | t.Fatal(err) |
| 731 | } |
| 732 | _, _, obody := get(t, c, fx.u("/apps/kass/releases")) |
| 733 | if !strings.Contains(obody, `name="production" value="off"`) { |
| 734 | t.Fatal("owner is not offered the turn-off control once bake is eligible — the member-only checks below would be vacuous") |
| 735 | } |
| 736 | if !strings.Contains(obody, `action="/a/bab/apps/kass/fasttrack"`) { |
| 737 | t.Fatalf("owner is not offered the fast-track control on a held head — the member-only checks below would be vacuous:\n%s", rungBlock(t, obody, "beta")) |
| 738 | } |
| 739 | |
| 740 | // ...then confirm the member does NOT, in that same eligible state. |
| 741 | _, _, mbody = get(t, cm, fx.u("/apps/kass/releases")) |
| 742 | if strings.Contains(mbody, `name="production" value="off"`) { |
| 743 | t.Error("member is offered the owner-only turn-off control once bake is on") |
| 744 | } |
| 745 | if strings.Contains(mbody, `action="/a/bab/apps/kass/fasttrack"`) { |
| 746 | t.Error("member is offered the owner-only fast-track control on a held head") |
| 747 | } |
| 748 | } |
| 749 | |
| 750 | // An UNREADABLE flag must not strand the app. The boxes fail toward baking |
| 751 | // (spec §4), so an app whose flag object will not verify — a root key |
| 752 | // rotation makes this every production app at once — bakes forever, and the |
| 753 | // page's error text alone offers no way out. Re-declaring is the repair: |
| 754 | // SetProduction CASes over whatever unverifiable bytes are there. |
| 755 | func TestLadderUnreadableFlagOffersRepair(t *testing.T) { |
| 756 | fx := newConsoleFixture(t) |
| 757 | c := signInPaul(t, fx) |
| 758 | if _, err := fx.rel.Put(context.Background(), |
| 759 | release.ProductionKey(release.OpsAccount, "kass"), []byte("{not a wrapper"), ""); err != nil { |
| 760 | t.Fatal(err) |
| 761 | } |
| 762 | _, _, body := get(t, c, fx.u("/apps/kass/releases")) |
| 763 | if !strings.Contains(body, "Safety delay: this app's setting can't be read right now, so the delay stays on.") { |
| 764 | t.Fatalf("no error line for an unverifiable flag:\n%s", body) |
| 765 | } |
| 766 | if !strings.Contains(body, `action="/a/bab/apps/kass/production"`) || |
| 767 | !strings.Contains(body, `name="production" value="on"`) { |
| 768 | t.Fatalf("an unreadable flag offers no repair form — the app bakes forever:\n%s", body) |
| 769 | } |
| 770 | if !strings.Contains(body, "Set it again") { |
| 771 | t.Fatalf("the repair control is not named as a re-declaration:\n%s", body) |
| 772 | } |
| 773 | // The safe direction only: turning OFF over an unverifiable flag is still |
| 774 | // the owner's, through the ordinary control, and is not offered here. |
| 775 | if strings.Contains(body, `name="production" value="off"`) { |
| 776 | t.Error("the error branch offers turn-off; only the safe direction belongs here") |
| 777 | } |
| 778 | // And the repair actually lands: a member's plain POST replaces the |
| 779 | // garbage with a verifiable flag. |
| 780 | csrf := sessionToken(t, fx, c) |
| 781 | if status, _, b := postForm(t, c, fx.u("/apps/kass/production"), |
| 782 | url.Values{"csrf": {csrf}, "production": {"on"}}); status != http.StatusSeeOther { |
| 783 | t.Fatalf("re-declare = %d, want 303:\n%s", status, b) |
| 784 | } |
| 785 | if f, err := release.GetProduction(context.Background(), fx.rel, fx.pub, |
| 786 | release.OpsAccount, "kass"); err != nil || f == nil || !f.Production { |
| 787 | t.Fatalf("flag = %+v, %v after the re-declaration, want a verifiable production: true", f, err) |
| 788 | } |
| 789 | } |
| 790 | |
| 791 | // The turning-off branch, through the HTML: an off-flag younger than the |
| 792 | // ratchet on an app with a held channel says hosts are still ratcheting, and |
| 793 | // offers the instant way back on (any member's, the safe direction). |
| 794 | func TestLadderTurningOffOffersTurnBackOn(t *testing.T) { |
| 795 | fx := newConsoleFixture(t) |
| 796 | shipVersion(t, fx, "kass", "v3") |
| 797 | seedChannel(t, fx, "kass", "beta", "v3") // a held channel: beta bakes |
| 798 | c := signInPaul(t, fx) |
| 799 | ctx := context.Background() |
| 800 | if err := release.SetProduction(ctx, fx.rel, fx.priv, release.OpsAccount, "kass", true, "test"); err != nil { |
| 801 | t.Fatal(err) |
| 802 | } |
| 803 | if err := release.SetProduction(ctx, fx.rel, fx.priv, release.OpsAccount, "kass", false, "test"); err != nil { |
| 804 | t.Fatal(err) |
| 805 | } |
| 806 | _, _, body := get(t, c, fx.u("/apps/kass/releases")) |
| 807 | if !strings.Contains(body, "turning off") { |
| 808 | t.Fatalf("a fresh off-flag on a held app does not say hosts are still ratcheting:\n%s", body) |
| 809 | } |
| 810 | if !strings.Contains(body, `name="production" value="on"`) || |
| 811 | !strings.Contains(body, "Turn back on") { |
| 812 | t.Fatalf("the turning-off state offers no way back on:\n%s", body) |
| 813 | } |
| 814 | // Not the default-off line: this app has an off-flag mid-ratchet, and |
| 815 | // telling the operator new versions go live immediately would be false |
| 816 | // for ~72h. |
| 817 | if strings.Contains(body, "new versions go live as soon as they're promoted") { |
| 818 | t.Error("mid-ratchet, the page claims new versions already go live immediately") |
| 819 | } |
| 820 | } |
| 821 | |
| 822 | // The prod-wiring nudge: a stable route with bake off gets the persistent |
| 823 | // recommendation line. |
| 824 | func TestLadderNudgesProdWiredBakeOff(t *testing.T) { |
| 825 | fx := newConsoleFixture(t) |
| 826 | c := signInPaul(t, fx) |
| 827 | // The fixture's kass.carlosapp.dev is on stable and the flag is off (the |
| 828 | // default): that IS the nudge condition. |
| 829 | _, _, body := get(t, c, fx.u("/apps/kass/releases")) |
| 830 | if !strings.Contains(body, "new versions go live immediately") { |
| 831 | t.Fatalf("no prod-wiring nudge:\n%s", body) |
| 832 | } |
| 833 | if err := release.SetProduction(context.Background(), fx.rel, fx.priv, |
| 834 | release.OpsAccount, "kass", true, "test"); err != nil { |
| 835 | t.Fatal(err) |
| 836 | } |
| 837 | if _, _, body := get(t, c, fx.u("/apps/kass/releases")); strings.Contains(body, "new versions go live immediately") { |
| 838 | t.Fatal("the nudge survived the flag turning on") |
| 839 | } |
| 840 | } |
| 841 | |