carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package 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
8import (
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.
27func 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.
43func 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.
54func 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.
68func 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.
83func 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.
128func 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.
165func 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.
200func 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.
224func 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.
261func 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.
287func 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).
321func 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.
347func 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.
379func 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.
426func 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.
458func 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.
488func 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.
550func 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).
579func 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.)
609func 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.
630func 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.
698func 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.
755func 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).
794func 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.
824func 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