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
3import (
4 "context"
5 "crypto/ed25519"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "net/http/cookiejar"
12 "net/http/httptest"
13 "net/url"
14 "os"
15 "path/filepath"
16 "slices"
17 "strings"
18 "testing"
19 "time"
20
21 "github.com/carlosframework/platform/internal/metrics"
22 "github.com/carlosframework/platform/internal/registry"
23 "github.com/carlosframework/platform/internal/release"
24 "github.com/carlosframework/platform/internal/status"
25 "github.com/carlosframework/platform/internal/version"
26)
27
28// consoleFixture is a whole console: fixture platform (registry + bucket
29// from view_test.go), console store, fake keymail, and the server under an
30// httptest listener whose URL is its own OAuth origin.
31type consoleFixture struct {
32 ts *httptest.Server
33 srv *Server
34 st *Store
35 fk *fakeKeymail
36 rel release.Store
37 regPath string
38 priv ed25519.PrivateKey
39 pub ed25519.PublicKey
40 sqid string // the fixture account's public identity
41}
42
43func newConsoleFixture(t *testing.T) *consoleFixture {
44 return newConsoleFixtureKeyed(t, true)
45}
46
47// newConsoleFixtureKeyed builds the fixture with or without the channel
48// signing key (withKey=false is the read-only deployment shape).
49func newConsoleFixtureKeyed(t *testing.T, withKey bool) *consoleFixture {
50 return newConsoleFixtureOn(t, withKey, nil)
51}
52
53// newConsoleFixtureOn is newConsoleFixtureKeyed over rel, when rel is not
54// nil, instead of the fixture's seeded DirStore.
55func newConsoleFixtureOn(t *testing.T, withKey bool, rel release.Store) *consoleFixture {
56 t.Helper()
57 regPath, relStore, priv, pub := buildFixtures(t)
58 // A test that needs a real S3 store (TestDBForwardMinio) passes its own;
59 // the fixture's seeded releases are then absent, which those tests do not
60 // read.
61 if rel != nil {
62 relStore = rel
63 }
64 st, err := Open(filepath.Join(t.TempDir(), "console.db"))
65 if err != nil {
66 t.Fatal(err)
67 }
68 t.Cleanup(func() { st.Close() })
69 fk := newFakeKeymail(t, "paul@keymail.dev")
70 // The origin must equal the listener's URL, which only exists after the
71 // listener does — hence the indirection.
72 var srv *Server
73 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
74 srv.ServeHTTP(w, r)
75 }))
76 t.Cleanup(ts.Close)
77 cfg := Config{
78 Origin: ts.URL,
79 Keymail: fk.srv.URL,
80 Registry: regPath,
81 Releases: relStore,
82 ReleasePub: pub,
83 AppScheme: "http", // instance polls reach httptest apps, not TLS
84 }
85 if withKey {
86 cfg.Key = priv
87 }
88 srv, err = NewServer(cfg, st)
89 if err != nil {
90 t.Fatal(err)
91 }
92 // Fixture route hosts don't resolve; keep the not-reporting verdict quick.
93 srv.instances.timeout = 100 * time.Millisecond
94 // paul owns the built-in ops account (created by NewServer's backfill),
95 // which claims kass — the shape a deployment is in once the pre-accounts
96 // grants have been mapped onto memberships.
97 if err := st.AddMember(OpsAccountID, "paul@keymail.dev", RoleOwner, time.Now()); err != nil {
98 t.Fatal(err)
99 }
100 if _, err := st.CreateAppIn(OpsAccountID, "kass", "paul@keymail.dev", time.Now()); err != nil {
101 t.Fatal(err)
102 }
103 return &consoleFixture{ts: ts, srv: srv, st: st, fk: fk, rel: relStore, regPath: regPath,
104 priv: priv, pub: pub, sqid: release.OpsAccount}
105}
106
107// path is a path under the fixture's account, and u is its full URL — every
108// app page and mutation lives under /a/<sqid>/ now.
109func (fx *consoleFixture) path(p string) string { return "/a/" + fx.sqid + p }
110func (fx *consoleFixture) u(p string) string { return fx.ts.URL + fx.path(p) }
111
112// channelVersion reads a channel pointer straight from the bucket.
113func (fx *consoleFixture) channelVersion(t *testing.T, app, channel string) *release.ChannelPointer {
114 t.Helper()
115 p, err := release.Channel(context.Background(), fx.rel, fx.pub, release.OpsAccount, app, channel)
116 if err != nil {
117 t.Fatalf("channel %s/%s: %v", app, channel, err)
118 }
119 return p
120}
121
122func (fx *consoleFixture) setUser(address string) {
123 fx.fk.mu.Lock()
124 defer fx.fk.mu.Unlock()
125 fx.fk.address = address
126}
127
128// newBrowser is a cookie-jarred client that never follows redirects, so
129// every hop is assertable.
130func newBrowser(t *testing.T) *http.Client {
131 t.Helper()
132 jar, err := cookiejar.New(nil)
133 if err != nil {
134 t.Fatal(err)
135 }
136 return &http.Client{
137 Jar: jar,
138 CheckRedirect: func(*http.Request, []*http.Request) error {
139 return http.ErrUseLastResponse
140 },
141 }
142}
143
144func get(t *testing.T, c *http.Client, u string) (status int, location, body string) {
145 t.Helper()
146 res, err := c.Get(u)
147 if err != nil {
148 t.Fatal(err)
149 }
150 defer res.Body.Close()
151 raw, err := io.ReadAll(res.Body)
152 if err != nil {
153 t.Fatal(err)
154 }
155 return res.StatusCode, res.Header.Get("Location"), string(raw)
156}
157
158func postForm(t *testing.T, c *http.Client, u string, form url.Values) (status int, location, body string) {
159 t.Helper()
160 res, err := c.PostForm(u, form)
161 if err != nil {
162 t.Fatal(err)
163 }
164 defer res.Body.Close()
165 raw, err := io.ReadAll(res.Body)
166 if err != nil {
167 t.Fatal(err)
168 }
169 return res.StatusCode, res.Header.Get("Location"), string(raw)
170}
171
172// signUp walks the whole OAuth ceremony: /auth/signin's redirect is parsed
173// for state + PKCE challenge (handed to the fake, which verifies the
174// verifier at exchange), then the callback lands with a code.
175func signUp(t *testing.T, fx *consoleFixture, c *http.Client, code string) {
176 t.Helper()
177 status, loc, _ := get(t, c, fx.ts.URL+"/auth/signin")
178 if status != http.StatusSeeOther {
179 t.Fatalf("signin = %d, want 303", status)
180 }
181 u, err := url.Parse(loc)
182 if err != nil {
183 t.Fatal(err)
184 }
185 km, _ := url.Parse(fx.fk.srv.URL)
186 if u.Host != km.Host || u.Path != "/oauth/authorize" {
187 t.Fatalf("authorize redirect went to %v, want %s/oauth/authorize", u, km.Host)
188 }
189 q := u.Query()
190 if q.Get("client_id") != fx.ts.URL || q.Get("redirect_uri") != fx.ts.URL+"/auth/callback" {
191 t.Fatalf("authorize params = %v", q)
192 }
193 if q.Get("scope") != "identify send" || q.Get("code_challenge_method") != "S256" {
194 t.Fatalf("authorize params = %v", q)
195 }
196 fx.fk.mu.Lock()
197 fx.fk.challenge = q.Get("code_challenge")
198 fx.fk.expectCode = code
199 fx.fk.mu.Unlock()
200 status, loc, _ = get(t, c, fx.ts.URL+"/auth/callback?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(q.Get("state")))
201 // Signing in lands you in an account — the MRU one, your first membership,
202 // or one minted on the spot.
203 if status != http.StatusSeeOther || !strings.HasPrefix(loc, "/a/") {
204 t.Fatalf("callback = %d -> %q, want 303 into an account", status, loc)
205 }
206}
207
208func sessionToken(t *testing.T, fx *consoleFixture, c *http.Client) string {
209 t.Helper()
210 u, _ := url.Parse(fx.ts.URL)
211 for _, ck := range c.Jar.Cookies(u) {
212 if ck.Name == sessionCookie {
213 return ck.Value
214 }
215 }
216 t.Fatal("no session cookie in jar")
217 return ""
218}
219
220func TestConsoleE2E(t *testing.T) {
221 fx := newConsoleFixture(t)
222 c := newBrowser(t)
223
224 if status, _, body := get(t, c, fx.ts.URL+"/healthz"); status != 200 || !strings.Contains(body, "ok") {
225 t.Fatalf("healthz = %d %q", status, body)
226 }
227 if status, loc, _ := get(t, c, fx.u("/")); status != http.StatusFound || loc != "/" {
228 t.Fatalf("signed-out /apps = %d -> %q, want 302 -> /", status, loc)
229 }
230 if _, _, body := get(t, c, fx.ts.URL+"/"); !strings.Contains(body, `action="/auth/continue"`) {
231 t.Fatal("landing page is missing the sign-in form")
232 }
233
234 signUp(t, fx, c, "code-1")
235
236 // First view: kass listed (and only kass), with the account-created flash.
237 _, _, body := get(t, c, fx.u("/"))
238 // The account home list shows the app, its apex host, a health verdict
239 // and the live version — not per-channel names/versions (that detail
240 // moved to the app's own Releases tab, asserted below).
241 for _, want := range []string{"Account created", "paul@keymail.dev", "kass", "v1", "Deployed", "1 test version"} {
242 if !strings.Contains(body, want) {
243 t.Fatalf("/apps missing %q in:\n%s", want, body)
244 }
245 }
246 if strings.Contains(body, "otherapp") {
247 t.Fatal("/apps lists an app the user does not own")
248 }
249 // The flash is one-shot.
250 if _, _, body := get(t, c, fx.u("/")); strings.Contains(body, "Account created") {
251 t.Fatal("welcome flash survived a second page view")
252 }
253
254 // Bake estimates only render for an app the production flag marks on
255 // (2026-08-04 console bake controls spec §7) — seed it so the "bakes
256 // until ~" assertion below still exercises the baking path rather than
257 // the new "nothing bakes without the flag" zeroing.
258 if err := release.SetProduction(context.Background(), fx.rel, fx.priv, release.OpsAccount, "kass", true, "test"); err != nil {
259 t.Fatal(err)
260 }
261
262 // The ladder, with channel names and versions on it, now lives on the
263 // Releases tab rather than the Overview page.
264 status, _, body := get(t, c, fx.u("/apps/kass/releases"))
265 if status != 200 {
266 t.Fatalf("/apps/kass/releases = %d", status)
267 }
268 for _, want := range []string{"v1", "stable", "canary/x", "v2"} {
269 if !strings.Contains(body, want) {
270 t.Fatalf("/apps/kass/releases missing %q in:\n%s", want, body)
271 }
272 }
273
274 // Releases tab: the Channels table, with the go-live estimate labeled.
275 status, _, rbody := get(t, c, fx.u("/apps/kass/releases"))
276 if status != 200 {
277 t.Fatalf("/apps/kass/releases = %d", status)
278 }
279 for _, want := range []string{"canary/x", "v2", "in ~", "<td>live now</td>", "estimates"} {
280 if !strings.Contains(rbody, want) {
281 t.Fatalf("/apps/kass/releases missing %q in:\n%s", want, rbody)
282 }
283 }
284
285 // Settings tab: the app's domains.
286 status, _, sbody := get(t, c, fx.u("/apps/kass/settings/domains"))
287 if status != 200 {
288 t.Fatalf("/apps/kass/settings = %d", status)
289 }
290 for _, want := range []string{"Domains", "kass.carlosapp.dev", "docs.kass.carlosapp.dev", "custom", "ready"} {
291 if !strings.Contains(sbody, want) {
292 t.Fatalf("/apps/kass/settings missing %q in:\n%s", want, sbody)
293 }
294 }
295
296 // Apps not owned 404 exactly like apps that don't exist.
297 if status, _, _ := get(t, c, fx.u("/apps/otherapp")); status != http.StatusNotFound {
298 t.Fatalf("/apps/otherapp = %d, want 404", status)
299 }
300 if status, _, _ := get(t, c, fx.u("/apps/nosuchapp")); status != http.StatusNotFound {
301 t.Fatalf("/apps/nosuchapp = %d, want 404", status)
302 }
303
304 // Signout: wrong CSRF refused, right CSRF signs out.
305 raw := sessionToken(t, fx, c)
306 if status, _, _ := postForm(t, c, fx.ts.URL+"/auth/signout", url.Values{"csrf": {"wrong"}}); status != http.StatusForbidden {
307 t.Fatalf("signout with bad csrf = %d, want 403", status)
308 }
309 if status, loc, _ := postForm(t, c, fx.ts.URL+"/auth/signout", url.Values{"csrf": {raw}}); status != http.StatusSeeOther || loc != "/" {
310 t.Fatalf("signout = %d -> %q, want 303 -> /", status, loc)
311 }
312 if status, _, _ := get(t, c, fx.u("/")); status != http.StatusFound {
313 t.Fatalf("post-signout /apps = %d, want redirect", status)
314 }
315}
316
317// TestHealthzCarriesTheBuild: the console's own build, from the PROCESS.
318//
319// This is the deploy watch's exit criterion, and it exists because both cheap
320// answers lie. X-Carlos-Version is stamped by the EDGE from the route row
321// (edge.go:1336), so adoption sets it while the old process keeps serving;
322// `systemctl is-active` says "active" for a process started from the previous
323// version just as happily (tito, 2026-08-09, seconds after adopt and restart
324// both reported success). Only the running process can answer this.
325func TestHealthzCarriesTheBuild(t *testing.T) {
326 fx := newConsoleFixture(t)
327 status, _, body := get(t, &http.Client{}, fx.ts.URL+"/healthz")
328 if status != 200 {
329 t.Fatalf("/healthz = %d, want 200", status)
330 }
331 if !strings.Contains(body, "ok") {
332 t.Fatalf("/healthz body = %q, want it to still start with ok (vet and the edge probe read this)", body)
333 }
334 fields := strings.Fields(body)
335 if len(fields) != 2 || fields[1] != version.Build {
336 t.Fatalf("/healthz body = %q, want exactly \"ok %s\" — the watch matches a whitespace-delimited token", body, version.Build)
337 }
338}
339
340func TestSecondOAuthIsPlainSignIn(t *testing.T) {
341 fx := newConsoleFixture(t)
342 c1 := newBrowser(t)
343 signUp(t, fx, c1, "code-1")
344 get(t, c1, fx.u("/")) // consume the flash
345
346 c2 := newBrowser(t)
347 signUp(t, fx, c2, "code-2") // same address, fresh ceremony: no re-signup
348 if _, _, body := get(t, c2, fx.u("/")); strings.Contains(body, "Account created") {
349 t.Fatal("second OAuth for a known address flashed account-created")
350 }
351 fx.fk.mu.Lock()
352 defer fx.fk.mu.Unlock()
353 if fx.fk.exchanges != 2 {
354 t.Fatalf("exchanges = %d, want 2", fx.fk.exchanges)
355 }
356}
357
358func TestEmptyStateWelcomesNewUser(t *testing.T) {
359 fx := newConsoleFixture(t)
360 fx.setUser("bob@keymail.dev") // no memberships for bob
361 c := newBrowser(t)
362 signUp(t, fx, c, "code-b")
363 // bob has no memberships, so signing in mints him a personal account and
364 // lands him in it — the empty state is that account's, not paul's.
365 _, home, _ := get(t, c, fx.ts.URL+"/")
366 status, _, body := get(t, c, fx.ts.URL+home)
367 if status != 200 {
368 t.Fatalf("account home = %d", status)
369 }
370 for _, want := range []string{
371 "Account created", "Welcome to the CARLOS platform",
372 "bob@keymail.dev", "Create app", `href="` + strings.TrimSuffix(home, "/") + `/apps/new"`,
373 } {
374 if !strings.Contains(body, want) {
375 t.Fatalf("empty state missing %q in:\n%s", want, body)
376 }
377 }
378 if strings.Contains(body, "kass") || strings.Contains(body, "otherapp") {
379 t.Fatal("empty state leaked someone else's apps")
380 }
381}
382
383// signInPaul signs a fresh browser in as the fixture's kass owner.
384func signInPaul(t *testing.T, fx *consoleFixture) *http.Client {
385 t.Helper()
386 c := newBrowser(t)
387 signUp(t, fx, c, "code-"+t.Name())
388 return c
389}
390
391func TestCreateApp(t *testing.T) {
392 fx := newConsoleFixture(t)
393 c := signInPaul(t, fx)
394 csrf := sessionToken(t, fx, c)
395
396 // Happy path: claim, own, land on the ship-your-first-release panel.
397 status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"zephyr"}})
398 if status != http.StatusSeeOther || loc != fx.path("/apps/zephyr") {
399 t.Fatalf("claim = %d -> %q, want 303 -> /apps/zephyr", status, loc)
400 }
401 status, _, body := get(t, c, fx.u("/apps/zephyr"))
402 if status != 200 {
403 t.Fatalf("/apps/zephyr = %d", status)
404 }
405 for _, want := range []string{"Created app zephyr", "nothing shipped yet"} {
406 if !strings.Contains(body, want) {
407 t.Fatalf("/apps/zephyr missing %q in:\n%s", want, body)
408 }
409 }
410 status, _, rbody := get(t, c, fx.u("/apps/zephyr/releases"))
411 if status != 200 {
412 t.Fatalf("/apps/zephyr/releases = %d", status)
413 }
414 for _, want := range []string{
415 "Nothing shipped yet",
416 "carlos ship --app zephyr --version $(git rev-parse --short HEAD) --target linux-arm64",
417 "carlos ship --app zephyr --kind static --version v1",
418 "promoted to channels from this page",
419 } {
420 if !strings.Contains(rbody, want) {
421 t.Fatalf("/apps/zephyr/releases missing %q in:\n%s", want, rbody)
422 }
423 }
424 apps, err := fx.st.AppsIn(OpsAccountID)
425 if err != nil || !strings.Contains(strings.Join(apps, " "), "zephyr") {
426 t.Fatalf("AppsIn = %v %v, want zephyr claimed in the account", apps, err)
427 }
428
429 // Taken is taken WITHIN THE ACCOUNT: the name just claimed and a name the
430 // pre-accounts grants hold are both "taken". (Another ACCOUNT claiming the
431 // same name is not a conflict; that's TestSameAppNameInTwoAccounts.)
432 //
433 // "orphan" — releases already in the bucket under THIS account's own
434 // prefix and no row — is no longer among them: since #79 an OWNER may
435 // claim such a name, because those objects are the account's own release
436 // history rather than a stranger's (apps_claim_test.go).
437 if err := release.Ship(context.Background(), fx.rel, release.OpsAccount, release.Manifest{
438 Schema: 1, App: "orphan", Version: "v1", Kind: "binary",
439 Artifacts: map[string]release.Artifact{"linux-amd64": {File: "orphan"}},
440 }, map[string][]byte{"linux-amd64": []byte("bin")}); err != nil {
441 t.Fatal(err)
442 }
443 if err := fx.st.Grant("cli@keymail.dev", "cligranted"); err != nil {
444 t.Fatal(err)
445 }
446 for _, name := range []string{"zephyr", "kass", "cligranted"} {
447 if status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {name}}); status != http.StatusSeeOther || loc != fx.path("/apps/new") {
448 t.Fatalf("claim %s = %d -> %q, want 303 -> the create form", name, status, loc)
449 }
450 if _, _, body := get(t, c, fx.u("/apps/new")); !strings.Contains(body, "That name is taken.") {
451 t.Fatalf("claim %s: no taken flash", name)
452 }
453 }
454
455 // The #79 exemption, through the browser door: paul owns this account, so
456 // the orphaned bucket objects under apps/bab/orphan/ are his to adopt and
457 // the claim lands on the app page like any other.
458 if status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"orphan"}}); status != http.StatusSeeOther || loc != fx.path("/apps/orphan") {
459 t.Fatalf("owner claim of orphan = %d -> %q, want 303 -> /apps/orphan (#79)", status, loc)
460 }
461
462 // Invalid names refused; CSRF enforced.
463 if status, _, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"Bad Name!"}}); status != http.StatusSeeOther {
464 t.Fatalf("invalid name = %d, want redirect with flash", status)
465 }
466 if _, _, body := get(t, c, fx.u("/apps/new")); !strings.Contains(body, "Invalid app name") {
467 t.Fatal("no invalid-name flash")
468 }
469 if status, _, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {"wrong"}, "name": {"fine-name"}}); status != http.StatusForbidden {
470 t.Fatalf("bad csrf claim = %d, want 403", status)
471 }
472}
473
474func TestAppTabBar(t *testing.T) {
475 fx := newConsoleFixture(t)
476 c := signInPaul(t, fx)
477 _, home, _ := get(t, c, fx.ts.URL+"/")
478 base := strings.TrimSuffix(home, "/") + "/apps/kass"
479 _, _, body := get(t, c, fx.ts.URL+base)
480 for _, want := range []string{
481 `class="tabs"`,
482 `href="` + base + `"`, `href="` + base + `/releases"`,
483 `href="` + base + `/instances"`, `href="` + base + `/activity"`,
484 `href="` + base + `/settings"`,
485 `aria-current="page">Overview</a>`,
486 } {
487 if !strings.Contains(body, want) {
488 t.Fatalf("app page missing %q", want)
489 }
490 }
491}
492
493func TestOverviewIsALadderNotAnEverythingPage(t *testing.T) {
494 fx := newConsoleFixture(t)
495 c := signInPaul(t, fx)
496 _, _, body := get(t, c, fx.u("/apps/kass"))
497 // The redesign moved the ladder itself, and the full listing sections it
498 // used to sit beside (Channels, Canaries, the Releases table), off
499 // Overview entirely — it is now a stats dashboard of tiles, not a second
500 // copy of the Releases tab.
501 for _, gone := range []string{`class="ladder"`, "<table", "<h2>Channels</h2>", "<h2>Canaries</h2>"} {
502 if strings.Contains(body, gone) {
503 t.Fatalf("overview still renders %q", gone)
504 }
505 }
506 for _, want := range []string{`class="tiles stat-tiles"`, "Running", "Web addresses", `/apps/kass/releases`} {
507 if !strings.Contains(body, want) {
508 t.Fatalf("overview missing %q", want)
509 }
510 }
511 assertNonMember404(t, fx, "/apps/kass")
512}
513
514func TestReleasesTab(t *testing.T) {
515 fx := newConsoleFixture(t)
516 c := signInPaul(t, fx)
517 status, _, body := get(t, c, fx.u("/apps/kass/releases"))
518 if status != 200 {
519 t.Fatalf("GET releases tab = %d", status)
520 }
521 for _, want := range []string{
522 `/apps/kass/deployments" aria-current="page">`,
523 "<h2>Channels</h2>", "<h2>Canaries</h2>", "All versions (",
524 } {
525 if !strings.Contains(body, want) {
526 t.Fatalf("releases tab missing %q", want)
527 }
528 }
529 // Every mutation form on the tab carries a back=releases hidden field, so
530 // its redirect returns here rather than to the overview. The fixture's
531 // count of 4 is 2 promote forms (one per version, v1 and v2) + 1 create-
532 // canary form + 1 retire-canary form (canary/x) — the rollback form
533 // contributes 0 because fixture stable is v1 with no history yet.
534 if n := strings.Count(body, `name="back" value="releases"`); n != 4 {
535 t.Fatalf(`releases tab has %d back=releases fields, want exactly 4`, n)
536 }
537 assertNonMember404(t, fx, "/apps/kass/releases")
538}
539
540// TestReleaseSummaryStripRenders pins the Make-live strip's form (M6, final
541// review): an owner with beta ahead of stable gets a real promote form
542// (channel=stable, version=beta's), a non-owner member sees no form at all —
543// just the "Only an account owner can make it live." note, since the fixture
544// is keyed and CanPromote is true either way.
545func TestReleaseSummaryStripRenders(t *testing.T) {
546 fx := newConsoleFixture(t)
547 shipVersion(t, fx, "kass", "v3")
548 seedChannel(t, fx, "kass", "beta", "v3")
549
550 c := signInPaul(t, fx)
551 _, _, body := get(t, c, fx.u("/apps/kass/releases"))
552 // The hidden channel=stable input is unique to the make-live strip's form
553 // (the per-row promote forms elsewhere on this tab use a <select>, not a
554 // hidden "stable" input), so its presence pins that specific form.
555 if !strings.Contains(body, `<input type="hidden" name="channel" value="stable">`) {
556 t.Fatal("owner view missing the make-live promote form's channel=stable input")
557 }
558 if !strings.Contains(body, `<input type="hidden" name="version" value="v3">`) {
559 t.Fatalf("make-live form missing its version=v3 hidden input:\n%s", body)
560 }
561 if !strings.Contains(body, `<input type="hidden" name="csrf" value="`) {
562 t.Fatal("make-live form missing its csrf input")
563 }
564 if strings.Contains(body, "Only an account owner can make it live.") {
565 t.Fatal("owner view should not show the owner-only note")
566 }
567
568 if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
569 t.Fatal(err)
570 }
571 fx.setUser("kim@keymail.dev")
572 member := newBrowser(t)
573 signUp(t, fx, member, "code-kim-release-strip")
574 _, _, mBody := get(t, member, fx.u("/apps/kass/releases"))
575 if strings.Contains(mBody, `<input type="hidden" name="channel" value="stable">`) {
576 t.Fatal("member view rendered the owner-only make-live form")
577 }
578 if !strings.Contains(mBody, "Only an account owner can make it live.") {
579 t.Fatal("member view missing the owner-only note")
580 }
581}
582
583// TestFirstPromoteChannelOptions (I4, final review): a fresh app has shipped
584// a release but has zero channels yet — the per-row promote select must
585// still offer the ladder rungs, so the first promotion has somewhere to go.
586func TestFirstPromoteChannelOptions(t *testing.T) {
587 fx := newConsoleFixture(t)
588 if _, err := fx.st.CreateAppIn(OpsAccountID, "freshapp", "paul@keymail.dev", time.Now()); err != nil {
589 t.Fatal(err)
590 }
591 shipVersion(t, fx, "freshapp", "v1")
592
593 c := signInPaul(t, fx)
594 _, _, body := get(t, c, fx.u("/apps/freshapp/releases"))
595 want := `<option value="edge">edge</option><option value="beta">beta</option><option value="stable">stable</option>`
596 if !strings.Contains(body, want) {
597 t.Fatalf("promote select missing the ladder-rung options in order:\n%s", body)
598 }
599 // The now-true copy this union restores: still says "ship a release, then
600 // promote it below" even though there IS a release, because the promote
601 // control the reader needs is right there in the versions table.
602 if !strings.Contains(body, "No channels yet") {
603 t.Fatal("zero-channel app missing the empty Channels-table copy")
604 }
605}
606
607// TestInstancesEmptyStateHintsStable (I2, final review): the Running tab's
608// empty state must steer a member at "give this a URL" onto stable, not
609// canary/<name> — a canary is a private test version, the wrong first door
610// for "how do I run this at all".
611func TestInstancesEmptyStateHintsStable(t *testing.T) {
612 fx := newConsoleFixture(t)
613 if _, err := fx.st.CreateAppIn(OpsAccountID, "routeless", "paul@keymail.dev", time.Now()); err != nil {
614 t.Fatal(err)
615 }
616 c := signInPaul(t, fx)
617 _, _, body := get(t, c, fx.u("/apps/routeless/instances"))
618 if !strings.Contains(body, "--channel stable") {
619 t.Fatalf("routeless app's Running empty state missing --channel stable:\n%s", body)
620 }
621 if strings.Contains(body, "canary/") {
622 t.Fatalf("routeless app's Running empty state still points at a canary channel:\n%s", body)
623 }
624}
625
626func TestInstancesTab(t *testing.T) {
627 fx := newConsoleFixture(t)
628 c := signInPaul(t, fx)
629 status, _, body := get(t, c, fx.u("/apps/kass/instances"))
630 if status != 200 {
631 t.Fatalf("GET instances tab = %d", status)
632 }
633 if !strings.Contains(body, `aria-current="page">Running</a>`) {
634 t.Fatal("tab not active")
635 }
636 assertNonMember404(t, fx, "/apps/kass/instances")
637}
638
639// assertNonMember404 signs bob (no membership in the fixture account) in and
640// asserts the account-scoped path answers him byte-identically to an account
641// that does not exist at all.
642func assertNonMember404(t *testing.T, fx *consoleFixture, sub string) {
643 t.Helper()
644 fx.setUser("bob@keymail.dev")
645 c := newBrowser(t)
646 signUp(t, fx, c, "code-nm-"+t.Name())
647 s1, _, b1 := get(t, c, fx.u(sub))
648 s2, _, b2 := get(t, c, fx.ts.URL+"/a/zzzzzzzz"+sub)
649 if s1 != http.StatusNotFound || s2 != http.StatusNotFound || b1 != b2 {
650 t.Fatalf("non-member leak on %s: member-URL=%d unknown-URL=%d identical=%v", sub, s1, s2, b1 == b2)
651 }
652}
653
654func TestCreateAppPage(t *testing.T) {
655 fx := newConsoleFixture(t)
656 c := signInPaul(t, fx)
657 status, _, body := get(t, c, fx.u("/apps/new"))
658 if status != 200 {
659 t.Fatalf("GET /apps/new = %d", status)
660 }
661 for _, want := range []string{
662 "Create an app", `action="` + fx.path("/apps/new") + `"`,
663 "lowercase letters, digits, and dashes",
664 } {
665 if !strings.Contains(body, want) {
666 t.Fatalf("create-app page missing %q", want)
667 }
668 }
669 // The account home now links here instead of embedding the form.
670 _, _, home := get(t, c, fx.u("/"))
671 if !strings.Contains(home, `/apps/new"`) {
672 t.Fatal("account home does not link to /apps/new")
673 }
674 if strings.Contains(home, `action="`+fx.path("/apps/new")+`"`) {
675 t.Fatal("account home still embeds the create form")
676 }
677 assertNonMember404(t, fx, "/apps/new")
678}
679
680func TestTeamPage(t *testing.T) {
681 fx := newConsoleFixture(t)
682 if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
683 t.Fatal(err)
684 }
685 tok, err := fx.st.CreateInvite(OpsAccountID, "sam@keymail.dev", "paul@keymail.dev", time.Now())
686 if err != nil {
687 t.Fatal(err)
688 }
689 c := signInPaul(t, fx)
690 status, _, body := get(t, c, fx.u("/team"))
691 if status != 200 {
692 t.Fatalf("GET /team = %d", status)
693 }
694 for _, want := range []string{
695 "Team", "people who can see and ship",
696 `action="` + fx.path("/invite") + `"`,
697 esc(copyText("team.confirm_note")),
698 } {
699 if !strings.Contains(body, want) {
700 t.Fatalf("team page missing %q", want)
701 }
702 }
703 // The account home no longer carries the member machinery.
704 _, _, home := get(t, c, fx.u("/"))
705 for _, gone := range []string{"Invite a teammate", "Hand over ownership"} {
706 if strings.Contains(home, gone) {
707 t.Fatalf("account home still shows %q", gone)
708 }
709 }
710 assertNonMember404(t, fx, "/team")
711
712 // A non-owner member sees the roster and nothing an owner alone should
713 // hold: no invite form, no member-administration forms, and — the one
714 // that matters — not the pending invite's TOKEN. That token is a bearer
715 // capability (whoever holds it can accept the invite as its addressee),
716 // so PendingInvites must stay behind the role==RoleOwner branch, not just
717 // behind membership.
718 fx.setUser("kim@keymail.dev")
719 member := newBrowser(t)
720 signUp(t, fx, member, "code-kim-team")
721 mStatus, _, mBody := get(t, member, fx.u("/team"))
722 if mStatus != 200 {
723 t.Fatalf("member GET /team = %d", mStatus)
724 }
725 if strings.Contains(mBody, tok) {
726 t.Fatal("member view leaked a pending invite's token")
727 }
728 for _, gone := range []string{"Invite a teammate", "members/remove", "members/transfer"} {
729 if strings.Contains(mBody, gone) {
730 t.Fatalf("member view of /team still shows %q", gone)
731 }
732 }
733}
734
735func TestAccountSettingsPage(t *testing.T) {
736 fx := newConsoleFixture(t)
737 if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
738 t.Fatal(err)
739 }
740 c := signInPaul(t, fx)
741 status, _, body := get(t, c, fx.u("/settings"))
742 if status != 200 {
743 t.Fatalf("GET /settings = %d", status)
744 }
745 for _, want := range []string{"Account settings", "<h2>Identity</h2>", "Your role"} {
746 if !strings.Contains(body, want) {
747 t.Fatalf("settings page missing %q", want)
748 }
749 }
750 // The fixture account has apps (kass), so no delete form may render —
751 // but the section explains itself instead of vanishing silently.
752 if strings.Contains(body, `action="`+fx.path("/delete")+`"`) {
753 t.Fatal("delete form rendered for an account with apps")
754 }
755 if !strings.Contains(body, "can't be deleted") {
756 t.Fatal("missing the why-not-deletable explanation")
757 }
758 assertNonMember404(t, fx, "/settings")
759
760 // A non-owner member sees the Identity section and nothing of the danger
761 // zone at all — not the heading, not the form, not the explanation. The
762 // section is owner-only display, same as Team's member-administration
763 // forms; the account having apps here is incidental, not the reason.
764 fx.setUser("kim@keymail.dev")
765 member := newBrowser(t)
766 signUp(t, fx, member, "code-kim-settings")
767 mStatus, _, mBody := get(t, member, fx.u("/settings"))
768 if mStatus != 200 {
769 t.Fatalf("member GET /settings = %d", mStatus)
770 }
771 if !strings.Contains(mBody, "<h2>Identity</h2>") {
772 t.Fatal("member view missing the Identity section")
773 }
774 for _, gone := range []string{"Delete this account", `action="` + fx.path("/delete") + `"`} {
775 if strings.Contains(mBody, gone) {
776 t.Fatalf("member view of /settings still shows %q", gone)
777 }
778 }
779}
780
781func TestAppNamedNewIsRefused(t *testing.T) {
782 fx := newConsoleFixture(t)
783 c := signInPaul(t, fx)
784 csrf := sessionToken(t, fx, c)
785 status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"new"}})
786 if status != http.StatusSeeOther || loc != fx.path("/apps/new") {
787 t.Fatalf("claiming 'new' = %d -> %q, want 303 back to the form", status, loc)
788 }
789 if apps, _ := fx.st.AppsIn(OpsAccountID); slices.Contains(apps, "new") {
790 t.Fatal("reserved name 'new' was claimed")
791 }
792}
793
794func TestPromoteFlow(t *testing.T) {
795 fx := newConsoleFixture(t)
796 c := signInPaul(t, fx)
797 csrf := sessionToken(t, fx, c)
798
799 // The releases tab offers promote forms per shipped version.
800 _, _, body := get(t, c, fx.u("/apps/kass/releases"))
801 if !strings.Contains(body, `action="/a/bab/apps/kass/promote"`) {
802 t.Fatal("no promote form on the releases tab")
803 }
804 if strings.Contains(body, "Promotions disabled") {
805 t.Fatal("keyed fixture rendered the disabled note")
806 }
807
808 // A build on no rung yet enters the ladder at edge, and the free-text
809 // field is what creates that channel the first time. (The fixture's own
810 // builds cannot move: v1 is already on stable and v2 is a canary, and the
811 // ladder promotes neither — see TestLadderRefusesASkip.)
812 shipVersion(t, fx, "kass", "v3")
813 status, loc, _ := postForm(t, c, fx.u("/apps/kass/promote"),
814 url.Values{"csrf": {csrf}, "version": {"v3"}, "channel": {"stable"}, "new_channel": {"edge"}, "confirmed": {"1"}})
815 if status != http.StatusSeeOther || loc != fx.path("/apps/kass") {
816 t.Fatalf("promote = %d -> %q", status, loc)
817 }
818 if p := fx.channelVersion(t, "kass", "edge"); p.Version != "v3" {
819 t.Fatalf("edge pointer = %+v, want v3", p)
820 }
821 _, _, body = get(t, c, fx.u("/apps/kass"))
822 // edge holds zero — pipelines spec: merged to main is picked up as it lands.
823 // The flash for an edge promotion contains only "Promoted v3 to edge." without
824 // the "bake window" text that a held channel gets on a PRODUCTION app.
825 if !strings.Contains(body, `<p class="banner">Promoted v3 to edge.</p>`) {
826 t.Fatalf("edge promotion flash incorrect:\n%s", body)
827 }
828
829 // One rung at a time, up to stable — which is the rung that asks for a
830 // passkey, so postGated walks the confirmation the console demands.
831 postForm(t, c, fx.u("/apps/kass/promote"),
832 url.Values{"csrf": {csrf}, "version": {"v3"}, "new_channel": {"beta"}, "confirmed": {"1"}})
833 if p := fx.channelVersion(t, "kass", "beta"); p.Version != "v3" {
834 t.Fatalf("beta pointer = %+v, want v3", p)
835 }
836 // beta HOLDS, but this app has no production flag — the default is not
837 // production (bake controls spec §2), so its boxes adopt beta immediately
838 // and the flash must not promise a window. The exact banner is asserted,
839 // not a substring: "no bake note here" is the property under test.
840 if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, `<p class="banner">Promoted v3 to beta.</p>`) {
841 t.Fatalf("beta promotion flash incorrect (a flagless app must not be promised a bake window):\n%s", body)
842 }
843 postGated(t, fx, c, fx.u("/apps/kass/promote"),
844 url.Values{"csrf": {csrf}, "version": {"v3"}, "channel": {"stable"}, "confirmed": {"1"}})
845 if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v3" || len(p.History) == 0 || p.History[0] != "v1" {
846 t.Fatalf("stable pointer = %+v, want v3 with v1 in history", p)
847 }
848
849 // Running the same promotion again is a no-op success, not a failure.
850 postGated(t, fx, c, fx.u("/apps/kass/promote"),
851 url.Values{"csrf": {csrf}, "version": {"v3"}, "channel": {"stable"}, "confirmed": {"1"}})
852 if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "already serves v3") {
853 t.Fatalf("a repeated promotion was not a no-op:\n%s", body)
854 }
855
856 // Unshipped version: refused before any gate, pointer unmoved.
857 postForm(t, c, fx.u("/apps/kass/promote"),
858 url.Values{"csrf": {csrf}, "version": {"v9"}, "channel": {"stable"}})
859 if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "not a shipped release") {
860 t.Fatal("no bad-version flash")
861 }
862 if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v3" {
863 t.Fatalf("stable moved to %s on a refused promote", p.Version)
864 }
865
866 // Invalid channel name refused.
867 postForm(t, c, fx.u("/apps/kass/promote"),
868 url.Values{"csrf": {csrf}, "version": {"v1"}, "new_channel": {"bad//name"}})
869 if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Invalid channel name.") {
870 t.Fatal("no invalid-channel flash")
871 }
872
873 // CSRF enforced; non-owners see the same 404 as everyone else.
874 if status, _, _ := postForm(t, c, fx.u("/apps/kass/promote"),
875 url.Values{"csrf": {"wrong"}, "version": {"v1"}, "channel": {"stable"}}); status != http.StatusForbidden {
876 t.Fatalf("bad csrf promote = %d, want 403", status)
877 }
878 fx.setUser("bob@keymail.dev")
879 cb := newBrowser(t)
880 signUp(t, fx, cb, "code-bob-p")
881 if status, _, _ := postForm(t, cb, fx.u("/apps/kass/promote"),
882 url.Values{"csrf": {sessionToken(t, fx, cb)}, "version": {"v2"}, "channel": {"stable"}}); status != http.StatusNotFound {
883 t.Fatalf("non-owner promote = %d, want 404", status)
884 }
885}
886
887func TestRollbackFlow(t *testing.T) {
888 fx := newConsoleFixture(t)
889 c := signInPaul(t, fx)
890 csrf := sessionToken(t, fx, c)
891
892 // Fixture stable is v1 with no history: no rollback button for it (nor
893 // for the single-promote canary), and a forced POST politely refuses.
894 _, _, body := get(t, c, fx.u("/apps/kass/releases"))
895 if strings.Contains(body, `name="channel" value="stable"`) || strings.Contains(body, `name="channel" value="canary/x"`) {
896 t.Fatal("rollback button rendered for a channel with no history")
897 }
898 postForm(t, c, fx.u("/apps/kass/rollback"), url.Values{"csrf": {csrf}, "channel": {"canary/x"}})
899 if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "no previous version") {
900 t.Fatal("no empty-history rollback flash")
901 }
902
903 // Put v2 over v1 on stable, then roll back: the pointer returns to v1 and
904 // v2 becomes the (re-)rollback target. Seeded through the release
905 // primitive rather than promoted through the console, because this test is
906 // about rollback and the ladder has its own (TestLadderRefusesASkip).
907 seedChannel(t, fx, "kass", "stable", "v2")
908 if _, _, body := get(t, c, fx.u("/apps/kass/releases")); !strings.Contains(body, `name="channel" value="stable"`) {
909 t.Fatal("no rollback button after stable gained history")
910 }
911 // stable is gated (owner + step-up, mirroring promoteGate's legacy path
912 // — Task 8/9 review), so this walks the passkey round trip like every
913 // other stable-rung action.
914 status, loc, _ := postGated(t, fx, c, fx.u("/apps/kass/rollback"),
915 url.Values{"csrf": {csrf}, "channel": {"stable"}, "confirmed": {"1"}})
916 if status != http.StatusSeeOther || loc != fx.path("/apps/kass") {
917 t.Fatalf("rollback = %d -> %q", status, loc)
918 }
919 p := fx.channelVersion(t, "kass", "stable")
920 if p.Version != "v1" || len(p.History) == 0 || p.History[0] != "v2" {
921 t.Fatalf("after rollback stable = %+v, want v1 with v2 first in history", p)
922 }
923 if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Rolled back stable to v1.") {
924 t.Fatal("no rollback flash")
925 }
926
927 // Same happy path, but posted from the releases tab (back=releases): the
928 // redirect must land back on the tab, not the app page. stable is v1 with
929 // v2 in history after the rollback above, so a second rollback is valid.
930 status, loc, _ = postGated(t, fx, c, fx.u("/apps/kass/rollback"),
931 url.Values{"csrf": {csrf}, "channel": {"stable"}, "back": {"releases"}, "confirmed": {"1"}})
932 if status != http.StatusSeeOther || loc != fx.path("/apps/kass/releases") {
933 t.Fatalf("rollback with back=releases = %d -> %q, want %q", status, loc, fx.path("/apps/kass/releases"))
934 }
935}
936
937// TestLegacyStableRollbackGatedByOwnerAndStepUp pins the Task 8/9 review's
938// pre-existing-gap fix: a legacy (pipeline-less) app's stable rollback used
939// to carry NO gate at all on this door — while the API door 403s it outright
940// and a PROMOTION onto stable takes owner + passkey (promoteGate). It now
941// mirrors promoteGate's legacy-path shape exactly: byte-identical 404 for a
942// non-owner, requireFreshAuth last for an owner. A non-stable rung is
943// unchanged — membership alone, no owner or step-up gate.
944func TestLegacyStableRollbackGatedByOwnerAndStepUp(t *testing.T) {
945 fx := newConsoleFixture(t)
946 seedChannel(t, fx, "kass", "stable", "v2") // history to roll back to
947 if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
948 t.Fatal(err)
949 }
950 // The owner, signed in FIRST — fx.setUser below points the fake OAuth
951 // provider at sam for the member checks, and signInPaul relies on the
952 // fixture's default (paul) at the moment it signs up.
953 c := signInPaul(t, fx)
954 csrf := sessionToken(t, fx, c)
955
956 // A member: the byte-identical 404, same as a promotion onto stable —
957 // and never the confirm card first (IMPORTANT 1, Task 9 review): the
958 // owner gate must fire BEFORE confirmRollback, not after a card that
959 // already conceded the action would otherwise proceed.
960 fx.setUser("sam@keymail.dev")
961 cm := newBrowser(t)
962 signUp(t, fx, cm, "code-sam-rollback-stable")
963 csrfSam := sessionToken(t, fx, cm)
964 if status, _, body := postForm(t, cm, fx.u("/apps/kass/rollback"),
965 url.Values{"csrf": {csrfSam}, "channel": {"stable"}}); status != http.StatusNotFound {
966 t.Fatalf("member stable rollback (unconfirmed) = %d, want 404", status)
967 } else if body != "404 page not found\n" {
968 t.Fatalf("member stable rollback body = %q, want the stdlib http.NotFound output — not a confirm card", body)
969 }
970 if status, _, _ := postForm(t, cm, fx.u("/apps/kass/rollback"),
971 url.Values{"csrf": {csrfSam}, "channel": {"stable"}, "confirmed": {"1"}}); status != http.StatusNotFound {
972 t.Fatalf("member stable rollback (confirmed) = %d, want 404", status)
973 }
974 if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v2" {
975 t.Fatalf("stable moved to %s on a refused member rollback", p.Version)
976 }
977 // The button itself is hidden from a member on the releases page — not
978 // merely refused on click.
979 if _, _, mbody := get(t, cm, fx.u("/apps/kass/releases")); strings.Contains(mbody, `name="channel" value="stable"`) {
980 t.Fatalf("member sees a Roll back control for stable, want it hidden:\n%s", mbody)
981 }
982
983 // The owner: a plain POST with no step-up does not move the pointer — it
984 // is redirected into Keymail's re-authentication, same as a stable
985 // promotion (TestLadderPromoteToStableNeedsStepUp). The fake OAuth
986 // provider's identity is reset to paul first — the member check above
987 // left it pointed at sam, and completing the step-up exchanges a code
988 // against whatever address is currently configured there.
989 fx.setUser("paul@keymail.dev")
990 status, loc, _ := postForm(t, c, fx.u("/apps/kass/rollback"),
991 url.Values{"csrf": {csrf}, "channel": {"stable"}, "confirmed": {"1"}})
992 if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
993 t.Fatalf("un-stepped-up owner stable rollback = %d -> %q, want a redirect into Keymail", status, loc)
994 }
995 if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v2" {
996 t.Fatalf("stable moved to %s with no passkey confirmation", p.Version)
997 }
998
999 // Completing the step-up carries the rollback out.
1000 if status, _, body := completeStepUp(t, fx, c, loc); status != http.StatusSeeOther {
1001 t.Fatalf("resumed rollback = %d:\n%s", status, body)
1002 }
1003 if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v1" {
1004 t.Fatalf("stable = %s after a confirmed, stepped-up rollback, want v1", p.Version)
1005 }
1006
1007 // A non-stable rung is unchanged: membership alone, no owner/step-up gate.
1008 shipVersion(t, fx, "kass", "v8")
1009 shipVersion(t, fx, "kass", "v9")
1010 seedChannel(t, fx, "kass", "beta", "v8")
1011 seedChannel(t, fx, "kass", "beta", "v9")
1012 fx.setUser("sam@keymail.dev")
1013 cm2 := newBrowser(t)
1014 signUp(t, fx, cm2, "code-sam-rollback-beta")
1015 status, loc, _ = postForm(t, cm2, fx.u("/apps/kass/rollback"),
1016 url.Values{"csrf": {sessionToken(t, fx, cm2)}, "channel": {"beta"}, "confirmed": {"1"}})
1017 if status != http.StatusSeeOther || loc != fx.path("/apps/kass") {
1018 t.Fatalf("member beta rollback = %d -> %q, want a plain 303 into the app page (no gate)", status, loc)
1019 }
1020 if p := fx.channelVersion(t, "kass", "beta"); p.Version != "v8" {
1021 t.Fatalf("beta = %s after a member's ungated rollback, want v8", p.Version)
1022 }
1023}
1024
1025// addRoute upserts a registry route into the fixture's registry.
1026func (fx *consoleFixture) addRoute(t *testing.T, rt registry.Route) {
1027 t.Helper()
1028 reg, err := registry.Open(fx.regPath)
1029 if err != nil {
1030 t.Fatal(err)
1031 }
1032 if err := reg.Upsert(rt); err != nil {
1033 t.Fatal(err)
1034 }
1035}
1036
1037func TestCanaryFlow(t *testing.T) {
1038 fx := newConsoleFixture(t)
1039 c := signInPaul(t, fx)
1040 csrf := sessionToken(t, fx, c)
1041
1042 // The releases tab: create form, and the fixture canary listed hostless.
1043 _, _, body := get(t, c, fx.u("/apps/kass/releases"))
1044 for _, want := range []string{
1045 `action="/a/bab/apps/kass/canary"`, `action="/a/bab/apps/kass/canary/retire"`,
1046 "canary/x", "no host yet", "Create canary",
1047 } {
1048 if !strings.Contains(body, want) {
1049 t.Fatalf("/apps/kass/releases missing %q in:\n%s", want, body)
1050 }
1051 }
1052
1053 // Create a canary: a fresh canary/rc channel pointed at v1, no bake.
1054 status, loc, _ := postForm(t, c, fx.u("/apps/kass/canary"),
1055 url.Values{"csrf": {csrf}, "name": {"rc"}, "version": {"v1"}})
1056 if status != http.StatusSeeOther || loc != fx.path("/apps/kass") {
1057 t.Fatalf("canary = %d -> %q", status, loc)
1058 }
1059 if p := fx.channelVersion(t, "kass", "canary/rc"); p.Version != "v1" {
1060 t.Fatalf("canary/rc pointer = %+v, want v1", p)
1061 }
1062 _, _, body = get(t, c, fx.u("/apps/kass"))
1063 // The flash names the API-backed verb. `carlos add` is box-local (it opens
1064 // the box's registry on local disk), so a dashboard must never send its
1065 // reader there — the whole point of the instances API path.
1066 for _, want := range []string{
1067 "Created canary rc", "no safety delay", "canary/rc",
1068 "carlos instances enable --app kass", "carlos instances create --app kass", "--channel canary/rc",
1069 } {
1070 if !strings.Contains(body, want) {
1071 t.Fatalf("post-create page missing %q in:\n%s", want, body)
1072 }
1073 }
1074 if strings.Contains(body, "carlos add ") {
1075 t.Errorf("post-create page still sends the reader at box-local `carlos add`:\n%s", body)
1076 }
1077
1078 // New means new: the existing canary refuses re-creation.
1079 postForm(t, c, fx.u("/apps/kass/canary"),
1080 url.Values{"csrf": {csrf}, "name": {"x"}, "version": {"v1"}})
1081 if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Canary x already exists") {
1082 t.Fatal("no already-exists flash")
1083 }
1084 if p := fx.channelVersion(t, "kass", "canary/x"); p.Version != "v2" {
1085 t.Fatalf("canary/x moved to %s on a refused create", p.Version)
1086 }
1087
1088 // Bad names, unshipped versions, CSRF.
1089 postForm(t, c, fx.u("/apps/kass/canary"),
1090 url.Values{"csrf": {csrf}, "name": {"Bad Name"}, "version": {"v1"}})
1091 if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Invalid canary name") {
1092 t.Fatal("no invalid-name flash")
1093 }
1094 postForm(t, c, fx.u("/apps/kass/canary"),
1095 url.Values{"csrf": {csrf}, "name": {"ok-name"}, "version": {"v9"}})
1096 if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "not a shipped release") {
1097 t.Fatal("no unshipped-version flash")
1098 }
1099 if status, _, _ := postForm(t, c, fx.u("/apps/kass/canary"),
1100 url.Values{"csrf": {"wrong"}, "name": {"rc2"}, "version": {"v1"}}); status != http.StatusForbidden {
1101 t.Fatalf("bad csrf canary = %d, want 403", status)
1102 }
1103
1104 // Non-owners get the same 404 as everyone else.
1105 fx.setUser("bob@keymail.dev")
1106 cb := newBrowser(t)
1107 signUp(t, fx, cb, "code-bob-c")
1108 if status, _, _ := postForm(t, cb, fx.u("/apps/kass/canary"),
1109 url.Values{"csrf": {sessionToken(t, fx, cb)}, "name": {"evil"}, "version": {"v1"}}); status != http.StatusNotFound {
1110 t.Fatalf("non-owner canary = %d, want 404", status)
1111 }
1112}
1113
1114func TestCanaryURLFromRoute(t *testing.T) {
1115 fx := newConsoleFixture(t)
1116 c := signInPaul(t, fx)
1117 csrf := sessionToken(t, fx, c)
1118
1119 // A host already follows canary/rc (routes can pre-exist their channel);
1120 // creating the canary makes it live there, and the console says so.
1121 fx.addRoute(t, registry.Route{Host: "rc.kass.carlosapp.dev", Kind: "instance",
1122 Status: "ready", App: "kass", Channel: "canary/rc", Canary: true})
1123 postForm(t, c, fx.u("/apps/kass/canary"),
1124 url.Values{"csrf": {csrf}, "name": {"rc"}, "version": {"v1"}})
1125 _, _, body := get(t, c, fx.u("/apps/kass"))
1126 if !strings.Contains(body, "live at https://rc.kass.carlosapp.dev") {
1127 t.Fatalf("create flash has no canary URL:\n%s", body)
1128 }
1129 // The Canaries table itself is on the Releases tab now.
1130 _, _, rbody := get(t, c, fx.u("/apps/kass/releases"))
1131 if !strings.Contains(rbody, `<a href="https://rc.kass.carlosapp.dev">rc.kass.carlosapp.dev</a>`) {
1132 t.Fatalf("canary table has no host link:\n%s", rbody)
1133 }
1134}
1135
1136func TestRetireCanary(t *testing.T) {
1137 fx := newConsoleFixture(t)
1138 c := signInPaul(t, fx)
1139 csrf := sessionToken(t, fx, c)
1140
1141 // A canary with a follower refuses to go: retiring the pointer would
1142 // strand the host.
1143 fx.addRoute(t, registry.Route{Host: "x.kass.carlosapp.dev", Kind: "instance",
1144 Status: "ready", App: "kass", Channel: "canary/x", Canary: true})
1145 postForm(t, c, fx.u("/apps/kass/canary/retire"), url.Values{"csrf": {csrf}, "name": {"x"}})
1146 if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "still has a host") {
1147 t.Fatal("no still-followed flash")
1148 }
1149 if p := fx.channelVersion(t, "kass", "canary/x"); p.Version != "v2" {
1150 t.Fatal("followed canary was retired anyway")
1151 }
1152
1153 // Unfollow, retire: the pointer is gone, the releases are not.
1154 reg, err := registry.Open(fx.regPath)
1155 if err != nil {
1156 t.Fatal(err)
1157 }
1158 if err := reg.Delete("x.kass.carlosapp.dev"); err != nil {
1159 t.Fatal(err)
1160 }
1161 status, loc, _ := postForm(t, c, fx.u("/apps/kass/canary/retire"), url.Values{"csrf": {csrf}, "name": {"x"}, "confirmed": {"1"}})
1162 if status != http.StatusSeeOther || loc != fx.path("/apps/kass") {
1163 t.Fatalf("retire = %d -> %q", status, loc)
1164 }
1165 if _, _, err := fx.rel.Get(context.Background(), release.ChannelKey(release.OpsAccount, "kass", "canary/x")); !errors.Is(err, os.ErrNotExist) {
1166 t.Fatalf("canary/x pointer still present: %v", err)
1167 }
1168 if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "kass", "v2"); err != nil {
1169 t.Fatalf("retire touched a shipped release: %v", err)
1170 }
1171 _, _, body := get(t, c, fx.u("/apps/kass"))
1172 if !strings.Contains(body, "Retired canary x") {
1173 t.Fatal("no retired flash")
1174 }
1175 if strings.Contains(body, "canary/x") {
1176 t.Fatal("retired canary still listed")
1177 }
1178
1179 // Retiring what isn't there is a polite no.
1180 postForm(t, c, fx.u("/apps/kass/canary/retire"), url.Values{"csrf": {csrf}, "name": {"ghost"}})
1181 if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "No canary named ghost") {
1182 t.Fatal("no no-such-canary flash")
1183 }
1184 if status, _, _ := postForm(t, c, fx.u("/apps/kass/canary/retire"),
1185 url.Values{"csrf": {"wrong"}, "name": {"x"}}); status != http.StatusForbidden {
1186 t.Fatalf("bad csrf retire = %d, want 403", status)
1187 }
1188}
1189
1190// TestRetireCanaryFailsClosedOnRegistryError pins the fail-closed contract: a
1191// registry that can't be read must never be read as "no hosts follow this
1192// channel" — that would let a transient hiccup delete a channel pointer live
1193// hosts still follow. Deliberately does NOT touch /apps/kass before the
1194// retire call (any earlier request would populate the console's process-wide
1195// registry handle cache and hide the failure) — the retire POST is the very
1196// first console-side access to this fixture's registry file, and by then the
1197// file on disk has been turned into a directory so the open fails outright.
1198func TestRetireCanaryFailsClosedOnRegistryError(t *testing.T) {
1199 fx := newConsoleFixture(t)
1200 c := signInPaul(t, fx)
1201 csrf := sessionToken(t, fx, c)
1202
1203 if err := os.Remove(fx.regPath); err != nil {
1204 t.Fatal(err)
1205 }
1206 if err := os.Mkdir(fx.regPath, 0o755); err != nil {
1207 t.Fatal(err)
1208 }
1209
1210 status, _, body := postForm(t, c, fx.u("/apps/kass/canary/retire"), url.Values{"csrf": {csrf}, "name": {"x"}})
1211 if status != http.StatusInternalServerError {
1212 t.Fatalf("retire with an unreadable registry = %d, want 500 (fail closed)", status)
1213 }
1214 if !strings.Contains(body, "Could not verify no hosts follow this channel") {
1215 t.Fatalf("retire-with-broken-registry body = %q, want the fail-closed refusal", body)
1216 }
1217}
1218
1219func TestAppPageInstances(t *testing.T) {
1220 fx := newConsoleFixture(t)
1221 c := signInPaul(t, fx)
1222
1223 // Before the app reports its own internal instances: no "Reported by the
1224 // app itself" disclosure — the tab is exactly the route-level view (which
1225 // always renders its own "Running" header, fixture routes and all).
1226 if _, _, body := get(t, c, fx.u("/apps/kass/instances")); strings.Contains(body, "Reported by the app itself") {
1227 t.Fatal("\"Reported by the app itself\" section rendered with nothing reporting")
1228 }
1229
1230 // Kass starts reporting its three training instances.
1231 app := newInstanceApp(t, kassReport)
1232 fx.addRoute(t, registry.Route{Host: app.host(t), Kind: "instance",
1233 Status: "ready", App: "kass"})
1234 _, _, body := get(t, c, fx.u("/apps/kass/instances"))
1235 for _, want := range []string{
1236 "<h2>Instances</h2>", "Reported by the app itself", "tracks its own internal workers",
1237 "riders-2026", "spring-camp", "trial-run",
1238 "eu-west-1", "archived", "read-only since June",
1239 } {
1240 if !strings.Contains(body, want) {
1241 t.Fatalf("/apps/kass/instances missing %q in:\n%s", want, body)
1242 }
1243 }
1244}
1245
1246// TestRecentActivity pins the activity tab against real, console-driven
1247// events (not synthetic RecordEvent calls) — TestActivityTab only checks the
1248// tab's chrome, so this is where seeded rows actually render. It also pins
1249// activityCap=50: past the cap, the oldest rows fall off and the newest 50
1250// (and no more) are what's on the page.
1251func TestRecentActivity(t *testing.T) {
1252 fx := newConsoleFixture(t)
1253 c := signInPaul(t, fx)
1254 csrf := sessionToken(t, fx, c)
1255
1256 // Fresh activity tab: no activity yet.
1257 if _, _, body := get(t, c, fx.u("/apps/kass/activity")); !strings.Contains(body, "Nothing yet") {
1258 t.Fatal("activity tab did not show its empty state before any action")
1259 }
1260
1261 // A promotion the ladder allows (a fresh build entering at edge), and a
1262 // rollback of a channel seeded with something to roll back to.
1263 shipVersion(t, fx, "kass", "v3")
1264 seedChannel(t, fx, "kass", "stable", "v2")
1265 postForm(t, c, fx.u("/apps/kass/promote"),
1266 url.Values{"csrf": {csrf}, "version": {"v3"}, "new_channel": {"edge"}, "confirmed": {"1"}})
1267 // stable is gated (owner + step-up, mirroring promoteGate's legacy path
1268 // — Task 8/9 review), so the confirmed post walks the passkey round trip.
1269 postGated(t, fx, c, fx.u("/apps/kass/rollback"),
1270 url.Values{"csrf": {csrf}, "channel": {"stable"}, "confirmed": {"1"}})
1271 postForm(t, c, fx.u("/apps/kass/canary"),
1272 url.Values{"csrf": {csrf}, "name": {"rc"}, "version": {"v1"}})
1273 postForm(t, c, fx.u("/apps/kass/canary/retire"),
1274 url.Values{"csrf": {csrf}, "name": {"rc"}, "confirmed": {"1"}})
1275
1276 _, _, body := get(t, c, fx.u("/apps/kass/activity"))
1277 for _, want := range []string{
1278 "paul@keymail.dev",
1279 "promoted v3 to edge", "rolled back stable to v1",
1280 "created canary rc at v1", "retired canary rc",
1281 } {
1282 if !strings.Contains(body, want) {
1283 t.Fatalf("activity missing %q in:\n%s", want, body)
1284 }
1285 }
1286 // Newest first: the retire outranks the promote.
1287 if strings.Index(body, "retired canary rc") > strings.Index(body, "promoted v3 to edge") {
1288 t.Fatal("activity is not newest-first")
1289 }
1290
1291 // Push the account's own event count well past the cap. The 4 real
1292 // actions above are now the oldest rows in the account, so this also
1293 // exercises them falling off — not just synthetic filler.
1294 for i := 0; i < 60; i++ {
1295 if err := fx.st.RecordEvent(OpsAccountID, "kass", "paul@keymail.dev",
1296 "marker", fmt.Sprintf("cap-marker-%03d", i), time.Now()); err != nil {
1297 t.Fatal(err)
1298 }
1299 }
1300 _, _, capped := get(t, c, fx.u("/apps/kass/activity"))
1301 if n := strings.Count(capped, "cap-marker-"); n != 50 {
1302 t.Fatalf("activity tab rendered %d rows past the cap, want exactly 50 (activityCap)", n)
1303 }
1304 if !strings.Contains(capped, "cap-marker-059") {
1305 t.Fatal("newest event fell off the capped activity tab")
1306 }
1307 if strings.Contains(capped, "cap-marker-000") || strings.Contains(capped, "promoted v3 to edge") {
1308 t.Fatal("activity tab kept rows older than its 50-row cap should allow")
1309 }
1310}
1311
1312func TestActivityTab(t *testing.T) {
1313 fx := newConsoleFixture(t)
1314 c := signInPaul(t, fx)
1315 status, _, body := get(t, c, fx.u("/apps/kass/activity"))
1316 if status != 200 {
1317 t.Fatalf("GET activity tab = %d", status)
1318 }
1319 for _, want := range []string{`aria-current="page">Activity</a>`} {
1320 if !strings.Contains(body, want) {
1321 t.Fatalf("activity tab missing %q", want)
1322 }
1323 }
1324 assertNonMember404(t, fx, "/apps/kass/activity")
1325}
1326
1327// NOTE: Assemble groups routes by app NAME with no account filter (view.go),
1328// so two accounts that happen to own an app of the same name share routes
1329// here — rival-account hosts render in this tab's Domains table, and the
1330// same leak reaches the overview tab's ladder. PRE-EXISTING, gated (the app
1331// page's session → membership → HasAppIn 404 runs before Assemble, so this
1332// is not a non-member leak), and neither introduced nor fixed by this task —
1333// fixing it there would also decide what the half-migrated AccountMismatch
1334// banner shows, a separate call. Source fix: view.go:294-308.
1335func TestAppSettingsTab(t *testing.T) {
1336 fx := newConsoleFixture(t)
1337 c := signInPaul(t, fx)
1338 status, _, body := get(t, c, fx.u("/apps/kass/settings"))
1339 if status != 200 {
1340 t.Fatalf("GET app settings = %d", status)
1341 }
1342 for _, want := range []string{
1343 `aria-current="page">Settings</a>`, "<h2>Identity</h2>",
1344 } {
1345 if !strings.Contains(body, want) {
1346 t.Fatalf("app settings missing %q", want)
1347 }
1348 }
1349 _, _, domains := get(t, c, fx.u("/apps/kass/settings/domains"))
1350 if !strings.Contains(domains, "Domains &amp; TLS") {
1351 t.Fatal("app Domains settings section is missing its heading")
1352 }
1353 assertNonMember404(t, fx, "/apps/kass/settings")
1354}
1355
1356func TestPromotionsDisabledWithoutKey(t *testing.T) {
1357 fx := newConsoleFixtureKeyed(t, false)
1358 c := signInPaul(t, fx)
1359 csrf := sessionToken(t, fx, c)
1360
1361 _, _, body := get(t, c, fx.u("/apps/kass"))
1362 if !strings.Contains(body, "Deploys are unavailable on this console — it has no signing key.") {
1363 t.Fatal("keyless console is missing the disabled note")
1364 }
1365 // The Domains section is a read — keyless (read-only) mode still renders it.
1366 if _, _, sbody := get(t, c, fx.u("/apps/kass/settings/domains")); !strings.Contains(sbody, "Domains") || !strings.Contains(sbody, "kass.carlosapp.dev") {
1367 t.Fatal("keyless console is missing the Domains section")
1368 }
1369 _, _, rbody := get(t, c, fx.u("/apps/kass/releases"))
1370 if strings.Contains(rbody, `action="/a/bab/apps/kass/promote"`) || strings.Contains(rbody, `action="/a/bab/apps/kass/rollback"`) ||
1371 strings.Contains(rbody, `action="/a/bab/apps/kass/canary"`) || strings.Contains(rbody, `action="/a/bab/apps/kass/canary/retire"`) {
1372 t.Fatal("keyless console rendered mutation forms")
1373 }
1374 if status, _, _ := postForm(t, c, fx.u("/apps/kass/promote"),
1375 url.Values{"csrf": {csrf}, "version": {"v2"}, "channel": {"stable"}}); status != http.StatusServiceUnavailable {
1376 t.Fatalf("keyless promote = %d, want 503", status)
1377 }
1378 if status, _, _ := postForm(t, c, fx.u("/apps/kass/rollback"),
1379 url.Values{"csrf": {csrf}, "channel": {"stable"}}); status != http.StatusServiceUnavailable {
1380 t.Fatalf("keyless rollback = %d, want 503", status)
1381 }
1382 if status, _, _ := postForm(t, c, fx.u("/apps/kass/canary"),
1383 url.Values{"csrf": {csrf}, "name": {"rc"}, "version": {"v1"}}); status != http.StatusServiceUnavailable {
1384 t.Fatalf("keyless canary create = %d, want 503", status)
1385 }
1386 if status, _, _ := postForm(t, c, fx.u("/apps/kass/canary/retire"),
1387 url.Values{"csrf": {csrf}, "name": {"x"}}); status != http.StatusServiceUnavailable {
1388 t.Fatalf("keyless canary retire = %d, want 503", status)
1389 }
1390 // The bake controls sign too — the flag is a root-signed object and
1391 // fast-track re-signs a pointer, so both are mutations a keyless console
1392 // must refuse rather than fail halfway through.
1393 if status, _, _ := postForm(t, c, fx.u("/apps/kass/production"),
1394 url.Values{"csrf": {csrf}, "production": {"on"}}); status != http.StatusServiceUnavailable {
1395 t.Fatalf("keyless production flag = %d, want 503", status)
1396 }
1397 if status, _, _ := postForm(t, c, fx.u("/apps/kass/fasttrack"),
1398 url.Values{"csrf": {csrf}, "channel": {"stable"}}); status != http.StatusServiceUnavailable {
1399 t.Fatalf("keyless fast-track = %d, want 503", status)
1400 }
1401 if f, err := release.GetProduction(context.Background(), fx.rel, fx.pub, release.OpsAccount, "kass"); err != nil || f != nil {
1402 t.Fatalf("keyless console wrote a production flag: %+v (%v)", f, err)
1403 }
1404 if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v1" {
1405 t.Fatalf("keyless console moved a pointer to %s", p.Version)
1406 }
1407 if p := fx.channelVersion(t, "kass", "canary/x"); p.Version != "v2" {
1408 t.Fatalf("keyless console touched canary/x (%s)", p.Version)
1409 }
1410}
1411
1412// ---- domains ----------------------------------------------------------------
1413
1414// TestClaimNeedsNoLinkageManifest: the app→account linkage IS the bucket key
1415// path now, so claiming an app writes NO apps/<app>/account.json (that
1416// manifest is retired) — and the account's own manifest is what the agent
1417// materializes from.
1418func TestClaimNeedsNoLinkageManifest(t *testing.T) {
1419 fx := newConsoleFixture(t)
1420 c := signInPaul(t, fx)
1421 csrf := sessionToken(t, fx, c)
1422
1423 if status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"zephyr"}}); status != http.StatusSeeOther || loc != fx.path("/apps/zephyr") {
1424 t.Fatalf("claim = %d -> %q", status, loc)
1425 }
1426 if _, _, err := fx.rel.Get(context.Background(), "apps/zephyr/account.json"); !errors.Is(err, os.ErrNotExist) {
1427 t.Errorf("claim wrote a retired linkage manifest: %v", err)
1428 }
1429 if _, _, err := fx.rel.Get(context.Background(), AccountManifestKey(fx.sqid)); err != nil {
1430 t.Errorf("no account manifest in the bucket: %v", err)
1431 }
1432}
1433
1434// TestAppPageBackfillsAccountManifest: an account whose manifest never
1435// reached the bucket (minted while it was unreachable, or the built-in ops
1436// account, which predates the feature) gets one written on a page view — and
1437// an existing manifest is never overwritten, because rewriting a different id
1438// there would re-home every platform domain the account owns.
1439func TestAppPageBackfillsAccountManifest(t *testing.T) {
1440 fx := newConsoleFixture(t)
1441 c := signInPaul(t, fx)
1442 key := AccountManifestKey(fx.sqid)
1443
1444 if _, _, err := fx.rel.Get(context.Background(), key); !errors.Is(err, os.ErrNotExist) {
1445 t.Fatalf("fixture already has a manifest: %v", err)
1446 }
1447 if status, _, _ := get(t, c, fx.u("/apps/kass")); status != 200 {
1448 t.Fatalf("app page = %d", status)
1449 }
1450 body, _, err := fx.rel.Get(context.Background(), key)
1451 if err != nil {
1452 t.Fatalf("view did not backfill the manifest: %v", err)
1453 }
1454 var m struct {
1455 ID int64 `json:"id"`
1456 Sqid string `json:"sqid"`
1457 }
1458 if err := json.Unmarshal(body, &m); err != nil {
1459 t.Fatalf("manifest not JSON: %v (%q)", err, body)
1460 }
1461 if m.ID != OpsAccountID || m.Sqid != fx.sqid {
1462 t.Errorf("backfilled manifest = %+v, want the ops account", m)
1463 }
1464
1465 // A pre-existing manifest is identity every box has already materialized.
1466 custom := []byte(`{"id":1,"sqid":"bab","name":"renamed by hand"}`)
1467 if _, err := fx.rel.Put(context.Background(), key, custom, m.Sqid); err == nil {
1468 t.Fatal("expected a CAS-guarded overwrite to need the current etag")
1469 }
1470 _, etag, _ := fx.rel.Get(context.Background(), key)
1471 if _, err := fx.rel.Put(context.Background(), key, custom, etag); err != nil {
1472 t.Fatal(err)
1473 }
1474 if status, _, _ := get(t, c, fx.u("/apps/kass")); status != 200 {
1475 t.Fatalf("app page = %d", status)
1476 }
1477 body, _, err = fx.rel.Get(context.Background(), key)
1478 if err != nil || string(body) != string(custom) {
1479 t.Errorf("view clobbered an existing manifest: %q %v", body, err)
1480 }
1481}
1482
1483// TestDomainViewStates is the pure Domains-row builder over route fixtures:
1484// type from the platform flag, unknown DNS as "" (rendered em-dash),
1485// guidance ONLY for non-pointing custom domains — except "unresolved" (our
1486// lookup failed; guidance would blame the member for our outage) — canary
1487// labeling preserved.
1488func TestDomainViewStates(t *testing.T) {
1489 edge := DNSTargets{EdgeIPs: []string{"203.0.113.7", "198.51.100.9"}}
1490 renew := time.Date(2026, 10, 12, 3, 0, 0, 0, time.UTC).Unix()
1491 cases := []struct {
1492 name string
1493 rt registry.Route
1494 dns DNSTargets
1495 want DomainView
1496 }{
1497 {"platform pointing ready with cert",
1498 registry.Route{Host: "kass.bab.oncarlos.com", Platform: true, Status: "ready",
1499 DNSStatus: "pointing", CertExpiresAt: renew},
1500 edge,
1501 DomainView{Domain: "kass.bab.oncarlos.com", Type: "platform", DNS: "pointing",
1502 TLS: "ready · renews 2026-10-12"}},
1503 {"custom elsewhere gets guidance",
1504 registry.Route{Host: "kass.training", Status: "ready", DNSStatus: "elsewhere"},
1505 edge,
1506 DomainView{Domain: "kass.training", Type: "custom", DNS: "elsewhere", TLS: "ready",
1507 Guidance: "Point an A record for kass.training at 203.0.113.7, 198.51.100.9. Checks run every few minutes."}},
1508 {"custom unknown: em-dash DNS, still guided",
1509 registry.Route{Host: "kass.training", Status: "issuing"},
1510 edge,
1511 DomainView{Domain: "kass.training", Type: "custom", DNS: "", TLS: "issuing",
1512 Guidance: "Point an A record for kass.training at 203.0.113.7, 198.51.100.9. Checks run every few minutes."}},
1513 {"custom pointing: no guidance",
1514 registry.Route{Host: "kass.training", Status: "ready", DNSStatus: "pointing"},
1515 edge,
1516 DomainView{Domain: "kass.training", Type: "custom", DNS: "pointing", TLS: "ready"}},
1517 {"custom unresolved: our lookup failed, no guidance — not the member's problem",
1518 registry.Route{Host: "kass.training", Status: "ready", DNSStatus: "unresolved"},
1519 edge,
1520 DomainView{Domain: "kass.training", Type: "custom", DNS: "unresolved", TLS: "ready"}},
1521 {"platform elsewhere: never guided (we made this domain; guidance is for THEIR dns)",
1522 registry.Route{Host: "kass.bab.oncarlos.com", Platform: true, Status: "ready", DNSStatus: "elsewhere"},
1523 edge,
1524 DomainView{Domain: "kass.bab.oncarlos.com", Type: "platform", DNS: "elsewhere", TLS: "ready"}},
1525 {"canary row keeps its label",
1526 registry.Route{Host: "rc.kass.bab.oncarlos.com", Platform: true, Status: "ready",
1527 DNSStatus: "pointing", Channel: "canary/rc", Canary: true},
1528 edge,
1529 DomainView{Domain: "rc.kass.bab.oncarlos.com", Type: "platform · canary/rc", DNS: "pointing", TLS: "ready"}},
1530 {"no known edge IPs: guidance still helps, without inventing an address",
1531 registry.Route{Host: "kass.training", Status: "ready", DNSStatus: "elsewhere"},
1532 DNSTargets{},
1533 DomainView{Domain: "kass.training", Type: "custom", DNS: "elsewhere", TLS: "ready",
1534 Guidance: "Point an A record for kass.training at this deployment's edge IP. Checks run every few minutes."}},
1535 }
1536 for _, c := range cases {
1537 t.Run(c.name, func(t *testing.T) {
1538 if got := domainView(c.rt, c.dns); got != c.want {
1539 t.Errorf("domainView:\n got %+v\n want %+v", got, c.want)
1540 }
1541 })
1542 }
1543}
1544
1545// TestDomainGuidanceSteering pins the custom-domain attach guidance across
1546// the steering axis (multi-region ingress spec, Part 4):
1547//
1548// - no steering hostname (every deployment today): byte-for-byte the
1549// sentence this console has always shown — the whole inertness claim;
1550// - steering armed + a subdomain: the CNAME to edge.<apps-domain> is the
1551// recommended form, with the A record still offered;
1552// - steering armed + an APEX: no CNAME is offered at all (it is illegal
1553// there) and the round-robin-without-latency-bias truth is stated —
1554// the spec's honesty rule. Apex-ness comes from the public suffix list,
1555// so a three-label apex (example.co.uk) is an apex and a three-label
1556// subdomain (app.example.com) is not.
1557func TestDomainGuidanceSteering(t *testing.T) {
1558 ips := []string{"203.0.113.7", "198.51.100.9"}
1559 steered := DNSTargets{EdgeIPs: ips, SteeringHost: "edge.oncarlos.com"}
1560 cases := []struct {
1561 name, host string
1562 dns DNSTargets
1563 want string
1564 }{
1565 {"unsteered deployment is untouched", "kass.training", DNSTargets{EdgeIPs: ips},
1566 "Point an A record for kass.training at 203.0.113.7, 198.51.100.9. Checks run every few minutes."},
1567 {"unsteered, no edge IPs", "kass.training", DNSTargets{},
1568 "Point an A record for kass.training at this deployment's edge IP. Checks run every few minutes."},
1569 {"steered subdomain is offered the CNAME", "www.kass.training", steered,
1570 "Point a CNAME for www.kass.training at edge.oncarlos.com — visitors then reach the nearest healthy edge, and a failed edge is withdrawn automatically. An A record at 203.0.113.7, 198.51.100.9 works too, but round-robins without latency bias. Checks run every few minutes."},
1571 {"steered apex keeps A records, honestly", "kass.training", steered,
1572 "Point an A record for kass.training at 203.0.113.7, 198.51.100.9. kass.training is an apex domain and cannot carry a CNAME, so its A records round-robin across the edges without latency bias. Checks run every few minutes."},
1573 {"multi-label public suffix is still an apex", "kass.co.uk", steered,
1574 "Point an A record for kass.co.uk at 203.0.113.7, 198.51.100.9. kass.co.uk is an apex domain and cannot carry a CNAME, so its A records round-robin across the edges without latency bias. Checks run every few minutes."},
1575 {"three labels under a plain TLD is a subdomain", "app.kass.co.uk", steered,
1576 "Point a CNAME for app.kass.co.uk at edge.oncarlos.com — visitors then reach the nearest healthy edge, and a failed edge is withdrawn automatically. An A record at 203.0.113.7, 198.51.100.9 works too, but round-robins without latency bias. Checks run every few minutes."},
1577 }
1578 for _, c := range cases {
1579 t.Run(c.name, func(t *testing.T) {
1580 if got := domainGuidance(c.host, c.dns); got != c.want {
1581 t.Errorf("domainGuidance(%q):\n got %q\n want %q", c.host, got, c.want)
1582 }
1583 })
1584 }
1585}
1586
1587// TestSteeringHostNormalization: the console never pastes an unusable
1588// steering hostname into member-facing guidance — a malformed value degrades
1589// to the A-record form rather than into the sentence.
1590func TestSteeringHostNormalization(t *testing.T) {
1591 for raw, want := range map[string]string{
1592 "": "",
1593 " Edge.OnCarlos.com ": "edge.oncarlos.com",
1594 "edge.oncarlos.com.": "edge.oncarlos.com",
1595 "*.oncarlos.com": "",
1596 "not a host": "",
1597 } {
1598 if got := normalizeSteeringHost(raw); got != want {
1599 t.Errorf("normalizeSteeringHost(%q) = %q, want %q", raw, got, want)
1600 }
1601 }
1602}
1603
1604// TestAppPageRendersDomains: the app page's Domains table renders from
1605// registry state — platform + custom rows, em-dash unknowns, and the
1606// guidance row beneath a non-pointing custom domain.
1607func TestAppPageRendersDomains(t *testing.T) {
1608 fx := newConsoleFixture(t)
1609 fx.addRoute(t, registry.Route{Host: "kass.bab.carlosapp.dev", Kind: "instance",
1610 Status: "ready", App: "kass", Platform: true, DNSStatus: "pointing", Account: "paul@keymail.dev"})
1611 c := signInPaul(t, fx)
1612 _, _, body := get(t, c, fx.u("/apps/kass/settings/domains"))
1613 for _, want := range []string{
1614 "Domains", "kass.bab.carlosapp.dev", "platform", "pointing",
1615 "kass.carlosapp.dev", "custom", "—",
1616 "Point an A record for kass.carlosapp.dev at this deployment&#39;s edge IP.",
1617 "Checks run every few minutes.",
1618 } {
1619 if !strings.Contains(body, want) {
1620 t.Fatalf("/apps/kass/settings missing %q in:\n%s", want, body)
1621 }
1622 }
1623}
1624
1625// seedFleetJSON writes a small status/fleet.json fixture through the real
1626// production path (one box snapshot, then BuildFleet + PublishFleet reading
1627// the fixture registry's routes) rather than hand-rolling JSON — so the test
1628// exercises the same shape /fleet will see in production.
1629func seedFleetJSON(t *testing.T, fx *consoleFixture) *status.Fleet {
1630 t.Helper()
1631 ctx := context.Background()
1632 if err := status.WriteBox(ctx, fx.rel, status.Box{
1633 GeneratedAt: time.Now(),
1634 Label: "ada-stargazing",
1635 Region: "eu-west-1",
1636 Routes: status.RouteCounts{Static: 2, Resident: 1},
1637 }); err != nil {
1638 t.Fatal(err)
1639 }
1640 reg, err := registry.Open(fx.regPath)
1641 if err != nil {
1642 t.Fatal(err)
1643 }
1644 routes, err := reg.List()
1645 if err != nil {
1646 t.Fatal(err)
1647 }
1648 f, err := status.BuildFleet(ctx, fx.rel, routes, time.Now(), 5*time.Minute)
1649 if err != nil {
1650 t.Fatal(err)
1651 }
1652 if err := status.PublishFleet(ctx, fx.rel, f); err != nil {
1653 t.Fatal(err)
1654 }
1655 return f
1656}
1657
1658// TestFleetOperatorOnly is Task 9's core gate: signed out, signed in but not
1659// an operator, and a revoked operator all get exactly the same 404 an
1660// actually-missing route gets — role existence must not be probeable.
1661// Granting flips the same session straight into the rendered fleet view.
1662func TestFleetOperatorOnly(t *testing.T) {
1663 fx := newConsoleFixture(t)
1664 seedFleetJSON(t, fx)
1665
1666 out := newBrowser(t)
1667 statusGhost, _, bodyGhost := get(t, out, fx.ts.URL+"/this-route-does-not-exist")
1668 if statusGhost != http.StatusNotFound {
1669 t.Fatalf("sanity: a missing route = %d, want 404", statusGhost)
1670 }
1671 if status, _, body := get(t, out, fx.ts.URL+"/fleet"); status != http.StatusNotFound || body != bodyGhost {
1672 t.Fatalf("signed-out /fleet = %d %q, want the same 404 as a missing route (%d %q)", status, body, statusGhost, bodyGhost)
1673 }
1674
1675 // Signed in and owns kass, but is not an operator: still the same 404.
1676 owner := signInPaul(t, fx)
1677 if status, _, body := get(t, owner, fx.ts.URL+"/fleet"); status != http.StatusNotFound || body != bodyGhost {
1678 t.Fatalf("non-operator /fleet = %d %q, want the same 404 as a missing route", status, body)
1679 }
1680
1681 if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
1682 t.Fatal(err)
1683 }
1684 status, _, body := get(t, owner, fx.ts.URL+"/fleet")
1685 if status != 200 || !strings.Contains(body, "Fleet status") {
1686 t.Errorf("operator fleet view: %d %s", status, body)
1687 }
1688 for _, want := range []string{"ada-stargazing", "eu-west-1", "Boxes", "Apps", "Services"} {
1689 if !strings.Contains(body, want) {
1690 t.Fatalf("/fleet missing %q in:\n%s", want, body)
1691 }
1692 }
1693
1694 // Revoked: back to the exact same 404.
1695 if err := fx.st.SetOperator("paul@keymail.dev", false); err != nil {
1696 t.Fatal(err)
1697 }
1698 if status, _, body := get(t, owner, fx.ts.URL+"/fleet"); status != http.StatusNotFound || body != bodyGhost {
1699 t.Fatalf("revoked operator /fleet = %d %q, want the same 404", status, body)
1700 }
1701}
1702
1703// TestFleetEmptyState: a deployment where the status-lease holder has never
1704// published status/fleet.json must render the page's empty state, never a
1705// 500 — the object simply not existing yet is not an error.
1706func TestFleetEmptyState(t *testing.T) {
1707 fx := newConsoleFixture(t)
1708 c := signInPaul(t, fx)
1709 if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
1710 t.Fatal(err)
1711 }
1712 status, _, body := get(t, c, fx.ts.URL+"/fleet")
1713 if status != 200 || !strings.Contains(body, "Fleet status") || !strings.Contains(body, "No snapshots yet") {
1714 t.Fatalf("empty fleet state: %d %s", status, body)
1715 }
1716}
1717
1718// TestFleetKeylessReadable: /fleet is a read, so a deployment with no
1719// CARLOS_RELEASE_KEY configured (promotions disabled, console read-only)
1720// must still serve it to an operator.
1721func TestFleetKeylessReadable(t *testing.T) {
1722 fx := newConsoleFixtureKeyed(t, false)
1723 seedFleetJSON(t, fx)
1724 c := signInPaul(t, fx)
1725 if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
1726 t.Fatal(err)
1727 }
1728 status, _, body := get(t, c, fx.ts.URL+"/fleet")
1729 if status != 200 || !strings.Contains(body, "Fleet status") {
1730 t.Fatalf("keyless /fleet = %d %s, want 200 with Fleet status", status, body)
1731 }
1732}
1733
1734// seedHostBoxes writes one fresh and one long-dark box snapshot, both
1735// carrying a private Metrics block, and publishes the merged feed over them —
1736// the exact pair /fleet's Hosts detail has to tell apart.
1737func seedHostBoxes(t *testing.T, fx *consoleFixture) {
1738 t.Helper()
1739 ctx := context.Background()
1740 now := time.Now()
1741 for _, b := range []status.Box{
1742 {
1743 GeneratedAt: now, Label: "ada-stargazing", Region: "eu-west-1",
1744 Metrics: &status.BoxMetrics{
1745 MemTotalBytes: pi64(2 << 30), MemAvailBytes: pi64(1 << 30),
1746 Load1: p(0.42), DiskFreeBytes: pi64(5 << 30),
1747 Release: "abc1234", Tunnel: "connected",
1748 },
1749 },
1750 {
1751 GeneratedAt: now.Add(-time.Hour), Label: "grace-kayaking", Region: "eu-west-2",
1752 Metrics: &status.BoxMetrics{
1753 MemTotalBytes: pi64(9 << 30), MemAvailBytes: pi64(7 << 30),
1754 Load1: p(8.25), DiskFreeBytes: pi64(3 << 30),
1755 Release: "beefcaf", Tunnel: "connected",
1756 },
1757 },
1758 } {
1759 if err := status.WriteBox(ctx, fx.rel, b); err != nil {
1760 t.Fatal(err)
1761 }
1762 }
1763 f, err := status.BuildFleet(ctx, fx.rel, nil, now, status.SnapshotPeriod)
1764 if err != nil {
1765 t.Fatal(err)
1766 }
1767 if err := status.PublishFleet(ctx, fx.rel, f); err != nil {
1768 t.Fatal(err)
1769 }
1770}
1771
1772// TestFleetHostsDetailSuppressesStaleMetrics is Task 7's core rule, on the one
1773// surface allowed to render raw numbers at all: the operator fleet page shows
1774// a live host's release, tunnel state, memory, load and free disk — and shows
1775// an unreachable host as unreachable, with how long it has been dark and NOT
1776// ONE of its last happy numbers. A dead box's final reading rendered as a
1777// current figure is the failure this whole task exists to prevent.
1778func TestFleetHostsDetailSuppressesStaleMetrics(t *testing.T) {
1779 fx := newConsoleFixture(t)
1780 seedHostBoxes(t, fx)
1781 c := signInPaul(t, fx)
1782 if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
1783 t.Fatal(err)
1784 }
1785 code, _, body := get(t, c, fx.ts.URL+"/fleet")
1786 if code != 200 {
1787 t.Fatalf("/fleet = %d: %s", code, body)
1788 }
1789 for _, want := range []string{
1790 "Hosts", "ada-stargazing", "abc1234", "connected",
1791 "1.0 GB", "2.0 GB", "0.42", "5.0 GB", // the fresh box's raw readings
1792 "grace-kayaking", "unreachable",
1793 } {
1794 if !strings.Contains(body, want) {
1795 t.Fatalf("/fleet hosts detail missing %q in:\n%s", want, body)
1796 }
1797 }
1798 for _, leaked := range []string{"beefcaf", "8.25", "7.0 GB", "9.0 GB", "3.0 GB"} {
1799 if strings.Contains(body, leaked) {
1800 t.Fatalf("unreachable host's stale reading %q rendered — a dark box must show no numbers:\n%s", leaked, body)
1801 }
1802 }
1803}
1804
1805// TestFleetHostsDegradeWhenUnreadable: the Hosts detail is an extra read on a
1806// page that already had a job. If the boxes can't be listed, the page still
1807// renders the merged feed it could read — metrics are never the reason /fleet
1808// 500s.
1809func TestFleetHostsDegradeWhenUnreadable(t *testing.T) {
1810 fx := newConsoleFixture(t)
1811 seedFleetJSON(t, fx)
1812 fx.srv.cfg.Releases = listFailStore{fx.rel}
1813 c := signInPaul(t, fx)
1814 if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
1815 t.Fatal(err)
1816 }
1817 code, _, body := get(t, c, fx.ts.URL+"/fleet")
1818 if code != 200 || !strings.Contains(body, "Fleet status") || !strings.Contains(body, "ada-stargazing") {
1819 t.Fatalf("/fleet with an unlistable bucket = %d, want the fleet feed still rendered:\n%s", code, body)
1820 }
1821}
1822
1823// TestFleetHostsEscapesHostSuppliedStrings: Release and Tunnel are strings a
1824// BOX writes into its own snapshot — the console reads them, it does not
1825// mint them — so a compromised or buggy host must not be able to put markup
1826// on an operator's page. html/template escapes them because they are plain
1827// strings all the way through (never template.HTML, unlike icons.go's
1828// vendored SVG); this test is what keeps that true.
1829func TestFleetHostsEscapesHostSuppliedStrings(t *testing.T) {
1830 fx := newConsoleFixture(t)
1831 if err := status.WriteBox(context.Background(), fx.rel, status.Box{
1832 GeneratedAt: time.Now(), Label: "leo-juggling", Region: "eu-west-1",
1833 Metrics: &status.BoxMetrics{Release: "<script>alert(1)</script>", Tunnel: "connected"},
1834 }); err != nil {
1835 t.Fatal(err)
1836 }
1837 c := signInPaul(t, fx)
1838 if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
1839 t.Fatal(err)
1840 }
1841 code, _, body := get(t, c, fx.ts.URL+"/fleet")
1842 if code != 200 {
1843 t.Fatalf("/fleet = %d: %s", code, body)
1844 }
1845 if strings.Contains(body, "<script>alert(1)</script>") {
1846 t.Fatalf("a host-supplied release string reached the page unescaped:\n%s", body)
1847 }
1848 if !strings.Contains(body, "&lt;script&gt;alert(1)&lt;/script&gt;") {
1849 t.Fatalf("escaped release string not rendered at all:\n%s", body)
1850 }
1851}
1852
1853// listFailStore is a store whose List always fails — everything else passes
1854// through, so fleet.json still reads and only the boxes sweep breaks.
1855type listFailStore struct{ release.Store }
1856
1857func (listFailStore) List(ctx context.Context, prefix string) ([]string, error) {
1858 return nil, errors.New("bucket list refused")
1859}
1860
1861// TestFleetMetricsOperatorOnly is Task 11's gate, mirroring TestFleetOperatorOnly
1862// for /fleet/metrics: signed out and signed in-but-not-operator both get
1863// exactly the same 404 an unrouted URL gets; an operator gets 200 with a
1864// seeded box label and unit name in the body.
1865func TestFleetMetricsOperatorOnly(t *testing.T) {
1866 fx := newConsoleFixture(t)
1867
1868 ctx := context.Background()
1869 at := time.Now().UTC().Add(-5 * time.Minute)
1870 chunk := metrics.RawChunk{
1871 V: 1,
1872 Box: "box-metrics-a",
1873 Hour: metrics.HourOf(at),
1874 Samples: []metrics.Sample{
1875 {
1876 At: at,
1877 Host: metrics.HostSample{CPUFrac: p(0.42), MemFrac: p(0.33), DiskFrac: p(0.5)},
1878 Units: map[string]metrics.UnitSample{
1879 "carlos-app@widget.service": {CPUFrac: p(0.11), MemBytes: pi64(1024)},
1880 },
1881 },
1882 },
1883 }
1884 if err := metrics.WriteRaw(ctx, fx.rel, chunk); err != nil {
1885 t.Fatal(err)
1886 }
1887
1888 out := newBrowser(t)
1889 statusGhost, _, bodyGhost := get(t, out, fx.ts.URL+"/definitely-not-a-route")
1890 if statusGhost != http.StatusNotFound {
1891 t.Fatalf("sanity: a missing route = %d, want 404", statusGhost)
1892 }
1893 if status, _, body := get(t, out, fx.ts.URL+"/fleet/metrics"); status != http.StatusNotFound || body != bodyGhost {
1894 t.Fatalf("signed-out /fleet/metrics = %d %q, want the same 404 as a missing route (%d %q)", status, body, statusGhost, bodyGhost)
1895 }
1896
1897 // Signed in and owns kass, but is not an operator: still the same 404.
1898 owner := signInPaul(t, fx)
1899 if status, _, body := get(t, owner, fx.ts.URL+"/fleet/metrics"); status != http.StatusNotFound || body != bodyGhost {
1900 t.Fatalf("non-operator /fleet/metrics = %d %q, want the same 404 as a missing route", status, body)
1901 }
1902
1903 if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
1904 t.Fatal(err)
1905 }
1906 status, _, body := get(t, owner, fx.ts.URL+"/fleet/metrics")
1907 if status != 200 {
1908 t.Fatalf("operator /fleet/metrics = %d, want 200: %s", status, body)
1909 }
1910 for _, want := range []string{"box-metrics-a", "carlos-app@widget.service"} {
1911 if !strings.Contains(body, want) {
1912 t.Fatalf("/fleet/metrics missing %q in:\n%s", want, body)
1913 }
1914 }
1915}
1916
1917// TestFleetMetricsEmptyState: no box has ever published metrics — the normal
1918// state right after a deployment — renders the page's empty state, never a
1919// 500.
1920func TestFleetMetricsEmptyState(t *testing.T) {
1921 fx := newConsoleFixture(t)
1922 c := signInPaul(t, fx)
1923 if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
1924 t.Fatal(err)
1925 }
1926 status, _, body := get(t, c, fx.ts.URL+"/fleet/metrics")
1927 if status != 200 || !strings.Contains(body, "Fleet metrics") {
1928 t.Fatalf("empty /fleet/metrics: %d %s", status, body)
1929 }
1930}
1931
1932// TestAppPageOwnerMetricsBoundary is Task 12's boundary, end to end: one box
1933// carries this owner's unit, ANOTHER tenant's unit, and host-level readings.
1934// The owner's app page shows their own instance's numbers and nothing else —
1935// no other tenant's unit, no host totals, no box name. A route with no unit
1936// data in the sweep (hibernating) renders as asleep, never as zeros.
1937func TestAppPageOwnerMetricsBoundary(t *testing.T) {
1938 fx := newConsoleFixture(t)
1939 fx.addRoute(t, registry.Route{Host: "owned.example.com", Kind: "instance",
1940 Status: "ready", App: "kass", Backing: registry.BackingUnit})
1941 fx.addRoute(t, registry.Route{Host: "asleep.example.com", Kind: "instance",
1942 Status: "ready", App: "kass", Backing: registry.BackingUnit, Hibernate: true})
1943 // The manually-added shape: awake and serving, but from a process no
1944 // carlos-app@ cgroup covers and no stamped unit names either (bespoke
1945 // unit / exec child), so the sweep has nothing for it AND nowhere to
1946 // look. Non-hibernating, so "asleep" would be a lie; unprovisioned and
1947 // unstamped, so "Not responding" would be too — expectsReadings can't
1948 // tell, so the row must read "No readings" (2026-08-10 tito false alarm).
1949 fx.addRoute(t, registry.Route{Host: "unmetered.example.com", Kind: "instance",
1950 Status: "ready", App: "kass"})
1951
1952 ctx := context.Background()
1953 at := time.Now().UTC().Add(-time.Minute) // inside metricsFreshWindow
1954 if err := metrics.WriteRaw(ctx, fx.rel, metrics.RawChunk{
1955 V: 1, Box: "box-secret-a", Hour: metrics.HourOf(at),
1956 Samples: []metrics.Sample{{
1957 At: at,
1958 // Host sentinels chosen so their rendered forms ("78%", "89%",
1959 // "44%") cannot collide with any unit number on this page — and
1960 // deliberately NOT 0.999, whose "100%" collides with the
1961 // layout's own `width: 100%` CSS.
1962 Host: metrics.HostSample{CPUFrac: p(0.777), MemFrac: p(0.888), DiskFrac: p(0.444)},
1963 Units: map[string]metrics.UnitSample{
1964 "carlos-app@owned.example.com.service": {CPUFrac: p(0.11), MemBytes: pi64(1024)},
1965 "carlos-app@other-tenant.example.com.service": {CPUFrac: p(0.55), MemBytes: pi64(4096)},
1966 },
1967 }},
1968 }); err != nil {
1969 t.Fatal(err)
1970 }
1971
1972 c := signInPaul(t, fx)
1973 status, _, body := get(t, c, fx.u("/apps/kass/instances"))
1974 if status != http.StatusOK {
1975 t.Fatalf("/apps/kass/instances = %d, want 200: %s", status, body)
1976 }
1977 // The per-host CPU/Mem readings now live as columns on the Instances
1978 // table itself (no separate Resources section) — "11%"/"1.0 KB" are
1979 // unique to owned.example.com's own row, so checking presence page-wide
1980 // still pins them to that route. Asleep vs Not responding vs No readings
1981 // is the Health column's job, not the metrics cell's, and the fixture
1982 // always carries other instance-kind rows (its own bystanders), so those
1983 // words are scoped to their own row below rather than checked page-wide
1984 // — a page-wide Contains would pass even if the wrong row said it.
1985 for _, want := range []string{
1986 "owned.example.com", "11%", "1.0 KB",
1987 "asleep.example.com", "unmetered.example.com",
1988 } {
1989 if !strings.Contains(body, want) {
1990 t.Fatalf("app page missing %q in:\n%s", want, body)
1991 }
1992 }
1993 if row := instanceRow(t, body, "asleep.example.com"); !strings.Contains(row, "Asleep") {
1994 t.Fatalf("asleep.example.com's own row did not read Asleep:\n%s", row)
1995 }
1996 if row := instanceRow(t, body, "unmetered.example.com"); !strings.Contains(row, "No readings") || !strings.Contains(row, "no data") {
1997 t.Fatalf("unmetered.example.com's own row did not read No readings / no data:\n%s", row)
1998 }
1999 // The boundary: nothing about the other tenant, the host, or the box.
2000 for _, forbidden := range []string{
2001 "other-tenant", "box-secret-a", // other tenant's unit, the box's name
2002 "55%", "4.0 KB", // the other tenant's readings
2003 "77.7", "78%", "89%", "44%", // host CPU/mem/disk totals
2004 } {
2005 if strings.Contains(body, forbidden) {
2006 t.Fatalf("app page leaked %q in:\n%s", forbidden, body)
2007 }
2008 }
2009}
2010
2011// TestAppPageSurvivesMetricsFailure: metrics are decoration. A bucket that
2012// fails every read must still leave the owner a fully rendered instances tab
2013// — the only tab that reads metrics now that the overview doesn't.
2014func TestAppPageSurvivesMetricsFailure(t *testing.T) {
2015 fx := newConsoleFixture(t)
2016 fx.addRoute(t, registry.Route{Host: "owned.example.com", Kind: "instance",
2017 Status: "ready", App: "kass", Backing: registry.BackingUnit})
2018 fx.srv.cfg.Releases = failingListStore{fx.rel}
2019 c := signInPaul(t, fx)
2020 status, _, body := get(t, c, fx.u("/apps/kass/instances"))
2021 if status != http.StatusOK {
2022 t.Fatalf("/apps/kass/instances with a broken metrics read = %d, want 200: %s", status, body)
2023 }
2024 if !strings.Contains(body, `aria-current="page">Running</a>`) {
2025 t.Fatalf("instances tab did not render its normal chrome: %s", body)
2026 }
2027 // Content, not just chrome: CPU/Mem readings now live as columns on the
2028 // route's own row, so a failed metrics read fails closed there — the
2029 // owned route still renders, with "no data" in place of numbers, not a
2030 // partially-rendered page.
2031 if !strings.Contains(body, "owned.example.com") || !strings.Contains(body, "no data") {
2032 t.Fatalf("instances tab did not render its route row with the metrics-failed state: %s", body)
2033 }
2034}
2035
2036// failingListStore fails exactly the List the metrics sweep starts with,
2037// leaving every other read (channels, manifests) working — the shape of a
2038// bucket permission gap on metrics/ alone.
2039type failingListStore struct{ release.Store }
2040
2041func (s failingListStore) List(ctx context.Context, prefix string) ([]string, error) {
2042 if strings.HasPrefix(prefix, "metrics/") {
2043 return nil, errors.New("boom")
2044 }
2045 return s.Store.List(ctx, prefix)
2046}
2047
2048// TestAppPageOwnerMetricsCrossAccountAppName is the app-NAME collision: app
2049// names are unique per account, not globally (store.AppAccounts), so two
2050// accounts can each own an app called "kass". Assemble groups routes by app
2051// name alone, so account B's routes arrive in account A's AppView — and the
2052// metrics allowlist must not follow them. A's page must carry none of B's
2053// instance readings.
2054func TestAppPageOwnerMetricsCrossAccountAppName(t *testing.T) {
2055 fx := newConsoleFixture(t)
2056 if err := fx.st.CreateAccount(42, "cdef", "Rival", "rival@keymail.dev", time.Now()); err != nil {
2057 t.Fatal(err)
2058 }
2059 if _, err := fx.st.CreateAppIn(42, "kass", "rival@keymail.dev", time.Now()); err != nil {
2060 t.Fatal(err)
2061 }
2062 fx.addRoute(t, registry.Route{Host: "owned.example.com", Kind: "instance",
2063 Status: "ready", App: "kass", Backing: registry.BackingUnit})
2064 // Same app name, different account — paul is not a member of cdef.
2065 fx.addRoute(t, registry.Route{Host: "rival.example.com", Kind: "instance",
2066 Status: "ready", App: "kass", Account: "cdef", Backing: registry.BackingUnit})
2067
2068 at := time.Now().UTC().Add(-time.Minute)
2069 if err := metrics.WriteRaw(context.Background(), fx.rel, metrics.RawChunk{
2070 V: 1, Box: "box-shared", Hour: metrics.HourOf(at),
2071 Samples: []metrics.Sample{{
2072 At: at,
2073 Units: map[string]metrics.UnitSample{
2074 "carlos-app@owned.example.com.service": {CPUFrac: p(0.11), MemBytes: pi64(1024)},
2075 "carlos-app@rival.example.com.service": {CPUFrac: p(0.33), MemBytes: pi64(8192)},
2076 },
2077 }},
2078 }); err != nil {
2079 t.Fatal(err)
2080 }
2081
2082 c := signInPaul(t, fx)
2083 status, _, body := get(t, c, fx.u("/apps/kass/instances"))
2084 if status != http.StatusOK {
2085 t.Fatalf("/apps/kass/instances = %d, want 200", status)
2086 }
2087 if !strings.Contains(body, "11%") {
2088 t.Fatalf("own instance's metrics missing in:\n%s", body)
2089 }
2090 // The other account's readings, by any spelling.
2091 for _, forbidden := range []string{"33%", "8.0 KB"} {
2092 if strings.Contains(body, forbidden) {
2093 t.Fatalf("app page leaked another account's reading %q in:\n%s", forbidden, body)
2094 }
2095 }
2096 // And the other account's host is not in the Resources section at all.
2097 //
2098 // NOTE: the assertion is scoped to Resources on purpose — this is the
2099 // /instances tab, which renders neither Domains nor the ladder, so
2100 // rival's host has nowhere else on THIS page to leak. It still leaks
2101 // on the settings and overview tabs; see the NOTE above TestAppSettingsTab.
2102 if res := resourcesSection(t, body); strings.Contains(res, "rival.example.com") {
2103 t.Fatalf("Resources section leaked another account's host:\n%s", res)
2104 }
2105}
2106
2107// resourcesSection returns just the app page's Resources block.
2108// resourcesSection used to cut out a dedicated "Resources" section; CPU/Mem
2109// readings now live as columns on the Instances table itself, so this cuts
2110// from that table's header to the end of the page's main content instead —
2111// the same "just the per-route metrics chrome" scope, at its new address.
2112func resourcesSection(t *testing.T, body string) string {
2113 t.Helper()
2114 i := strings.Index(body, "<h2>Instances</h2>")
2115 if i < 0 {
2116 t.Fatalf("no Instances section in:\n%s", body)
2117 }
2118 j := strings.Index(body[i:], "</main>")
2119 if j < 0 {
2120 t.Fatalf("Running section never ended:\n%s", body[i:])
2121 }
2122 return body[i : i+j]
2123}
2124
2125// instanceRow returns the Running table's one <tr> naming the given host —
2126// sliced from the host's own anchor through the row's closing tag. The
2127// fixture always carries more than one route (its own bystanders plus
2128// whatever a test adds), so a page-wide strings.Contains on a Health word
2129// like "Not responding" or "Asleep" can be satisfied by SOME row without
2130// pinning it to the row under test; scoping to the row is what actually
2131// proves it.
2132func instanceRow(t *testing.T, body, host string) string {
2133 t.Helper()
2134 i := strings.Index(body, host)
2135 if i < 0 {
2136 t.Fatalf("no row for host %q in:\n%s", host, body)
2137 }
2138 row := body[i:]
2139 j := strings.Index(row, "</tr>")
2140 if j < 0 {
2141 t.Fatalf("row for host %q never closed in:\n%s", host, row)
2142 }
2143 return row[:j]
2144}
2145
2146// TestAppPageOwnerMetricsStaleBoxIsNotCurrent: a box that stopped publishing
2147// keeps its last sample inside the 48h sweep window forever. Rendering that
2148// as the instance's CURRENT figure would be a lie with no expiry, so a box
2149// whose newest sample is older than the freshness window reads as no recent
2150// readings — the same state a hibernating instance gets.
2151func TestAppPageOwnerMetricsStaleBoxIsNotCurrent(t *testing.T) {
2152 fx := newConsoleFixture(t)
2153 fx.addRoute(t, registry.Route{Host: "owned.example.com", Kind: "instance",
2154 Status: "ready", App: "kass", Backing: registry.BackingUnit})
2155
2156 at := time.Now().UTC().Add(-3 * time.Hour) // inside the sweep, far outside fresh
2157 if err := metrics.WriteRaw(context.Background(), fx.rel, metrics.RawChunk{
2158 V: 1, Box: "box-dead", Hour: metrics.HourOf(at),
2159 Samples: []metrics.Sample{{
2160 At: at,
2161 Units: map[string]metrics.UnitSample{
2162 "carlos-app@owned.example.com.service": {CPUFrac: p(0.23), MemBytes: pi64(2048)},
2163 },
2164 }},
2165 }); err != nil {
2166 t.Fatal(err)
2167 }
2168
2169 c := signInPaul(t, fx)
2170 status, _, body := get(t, c, fx.u("/apps/kass/instances"))
2171 if status != http.StatusOK {
2172 t.Fatalf("/apps/kass/instances = %d, want 200", status)
2173 }
2174 // The CPU/Mem cell reads the same "no data" a hibernating instance gets
2175 // (staleness is not distinguishable from any other non-reporting cause at
2176 // that cell); the route isn't hibernating, so the Health column must say
2177 // so plainly rather than claim Asleep. Scoped to owned.example.com's own
2178 // row: the fixture also carries kass.carlosapp.dev, an instance-kind
2179 // route that is never metered and never hibernating, which renders "No
2180 // readings" (it carries no Backing/Unit/Provisioned — expectsReadings
2181 // can't tell) on every run of this page regardless of what this test
2182 // does — a page-wide Contains would pass even with the wrong route
2183 // hibernating.
2184 row := instanceRow(t, body, "owned.example.com")
2185 if !strings.Contains(row, "Not responding") || !strings.Contains(row, "no data") {
2186 t.Fatalf("stale box did not render as not responding with no data:\n%s", row)
2187 }
2188 for _, forbidden := range []string{"23%", "2.0 KB"} {
2189 if strings.Contains(body, forbidden) {
2190 t.Fatalf("app page rendered a %v-old reading as current (%q):\n%s",
2191 time.Since(at).Truncate(time.Hour), forbidden, resourcesSection(t, body))
2192 }
2193 }
2194}
2195
2196// TestFleetMetricsRendersFiringAlertsWithUnits pins two things the alerts
2197// section had never actually been rendered in a test for: it appears ABOVE
2198// the box cards (an operator opening the page sees what is on fire before
2199// scrolling anything), and each rule's value carries ITS OWN unit. Every
2200// alert value used to render through the percent formatter, so the first lag
2201// alert to fire — lag is seconds, not a fraction — would have read
2202// "92000%" instead of "920s".
2203func TestFleetMetricsRendersFiringAlertsWithUnits(t *testing.T) {
2204 fx := newConsoleFixture(t)
2205 ctx := context.Background()
2206
2207 at := time.Now().UTC().Add(-2 * time.Minute)
2208 if err := metrics.WriteRaw(ctx, fx.rel, metrics.RawChunk{
2209 V: 1, Box: "box-alerting", Hour: metrics.HourOf(at),
2210 Samples: []metrics.Sample{{At: at, Host: metrics.HostSample{CPUFrac: p(0.42), DiskFrac: p(0.95)}}},
2211 }); err != nil {
2212 t.Fatal(err)
2213 }
2214 state := metrics.AlertState{
2215 V: 1, Box: "box-alerting", UpdatedAt: at,
2216 Rules: map[string]metrics.RuleState{
2217 "disk": {Firing: true, Value: 0.95, FiredAt: at},
2218 "lag": {Firing: true, Value: 920, FiredAt: at},
2219 },
2220 }
2221 body, err := json.Marshal(state)
2222 if err != nil {
2223 t.Fatal(err)
2224 }
2225 if _, err := fx.rel.Put(ctx, metrics.AlertKey("box-alerting"), body, ""); err != nil {
2226 t.Fatal(err)
2227 }
2228
2229 c := signInPaul(t, fx)
2230 if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
2231 t.Fatal(err)
2232 }
2233 status, _, page := get(t, c, fx.ts.URL+"/fleet/metrics")
2234 if status != 200 {
2235 t.Fatalf("/fleet/metrics = %d: %s", status, page)
2236 }
2237
2238 alertsAt := strings.Index(page, ">Alerts<")
2239 cardAt := strings.Index(page, "<h2>box-alerting</h2>")
2240 if alertsAt < 0 {
2241 t.Fatalf("no alerts section rendered for a firing box:\n%s", page)
2242 }
2243 if cardAt < 0 {
2244 t.Fatalf("no box card rendered:\n%s", page)
2245 }
2246 if alertsAt > cardAt {
2247 t.Errorf("alerts section renders after the first box card (%d > %d) — firing alerts belong at the top", alertsAt, cardAt)
2248 }
2249 if !strings.Contains(page, "95%") {
2250 t.Errorf("disk alert value not rendered as a percent:\n%s", page)
2251 }
2252 if !strings.Contains(page, "920s") {
2253 t.Errorf("lag alert value not rendered in seconds:\n%s", page)
2254 }
2255 if strings.Contains(page, "92000%") {
2256 t.Errorf("lag alert value rendered as a percentage — lag is seconds:\n%s", page)
2257 }
2258}
2259
2260// TestReleasesPageShowsLabelsAndTags: the Releases tab's own read joins
2261// Versions against ListTags (view.go's tagByRelease, release versioning
2262// spec 2026-08-08) — a shipped iteration's label and notes are visible, and
2263// once a tag has been cut for it, the tag pill and its changelog show too.
2264func TestReleasesPageShowsLabelsAndTags(t *testing.T) {
2265 fx := newConsoleFixture(t)
2266 c := signInPaul(t, fx)
2267 ctx := context.Background()
2268
2269 if err := release.Ship(ctx, fx.rel, release.OpsAccount, release.Manifest{
2270 Schema: 1, App: "kass", Version: "0.5.0-1", Kind: "binary", BuiltAt: "2026-08-08T10:00:00Z",
2271 Label: "fixing modals", Notes: "- x",
2272 Artifacts: map[string]release.Artifact{"linux-amd64": {File: "kass"}},
2273 }, map[string][]byte{"linux-amd64": []byte("bin")}); err != nil {
2274 t.Fatal(err)
2275 }
2276 if _, _, err := release.EnsureTag(ctx, fx.rel, release.OpsAccount, "kass", "0.5.0-1", "paul@keymail.dev", time.Now()); err != nil {
2277 t.Fatal(err)
2278 }
2279
2280 status, _, body := get(t, c, fx.u("/apps/kass/releases"))
2281 if status != http.StatusOK {
2282 t.Fatalf("/apps/kass/releases = %d", status)
2283 }
2284 for _, want := range []string{"fixing modals", "0.5.0-1", "- x"} {
2285 if !strings.Contains(body, want) {
2286 t.Fatalf("releases page missing %q in:\n%s", want, body)
2287 }
2288 }
2289 // The tag pill sits on the Releases table's Version cell as a chip, not
2290 // merely somewhere on the page.
2291 if !strings.Contains(body, `<span class="chip chip-env">0.5.0</span>`) {
2292 t.Fatalf("no tag pill for 0.5.0 in:\n%s", body)
2293 }
2294 // The Tags section below the Releases table carries the compiled
2295 // changelog — the same notes text, reached a different way.
2296 if !strings.Contains(body, "<h2>Tags</h2>") {
2297 t.Fatalf("no Tags section in:\n%s", body)
2298 }
2299}
2300
2301// TestChannelRowShowsStableTag: a channel whose current version carries a
2302// tag shows it next to the version in the Channels table.
2303func TestChannelRowShowsStableTag(t *testing.T) {
2304 fx := newConsoleFixture(t)
2305 c := signInPaul(t, fx)
2306
2307 shipVersion(t, fx, "kass", "0.6.0-1")
2308 seedChannel(t, fx, "kass", "stable", "0.6.0-1")
2309 if _, _, err := release.EnsureTag(context.Background(), fx.rel, release.OpsAccount, "kass", "0.6.0-1", "paul@keymail.dev", time.Now()); err != nil {
2310 t.Fatal(err)
2311 }
2312
2313 _, _, body := get(t, c, fx.u("/apps/kass/releases"))
2314 if !strings.Contains(body, `<td><code>0.6.0-1</code> <span class="chip chip-env">0.6.0</span></td>`) {
2315 t.Fatalf("stable channel row missing tag pill for 0.6.0:\n%s", body)
2316 }
2317}
2318
2319// TestPromoteFlashNamesTag drives the browser promote-to-stable path (the
2320// same ladder walk TestPromoteFlow uses) with an iteration-shaped version,
2321// and checks the flash names the tag PromoteOnLadder cut. The repeat
2322// promotion is the CARRY-FORWARD binding from the task-10 brief: a no-op
2323// still names the tag, because PromoteOnLadder's stableTag re-cuts (or
2324// heals) it on every stable landing, no-op included, and a flash that went
2325// quiet on a repeat would hide a healed tag from the operator who just
2326// asked for one.
2327func TestPromoteFlashNamesTag(t *testing.T) {
2328 fx := newConsoleFixture(t)
2329 c := signInPaul(t, fx)
2330 csrf := sessionToken(t, fx, c)
2331
2332 shipVersion(t, fx, "kass", "0.7.0-1")
2333 postForm(t, c, fx.u("/apps/kass/promote"),
2334 url.Values{"csrf": {csrf}, "version": {"0.7.0-1"}, "new_channel": {"edge"}, "confirmed": {"1"}})
2335 postForm(t, c, fx.u("/apps/kass/promote"),
2336 url.Values{"csrf": {csrf}, "version": {"0.7.0-1"}, "new_channel": {"beta"}, "confirmed": {"1"}})
2337 postGated(t, fx, c, fx.u("/apps/kass/promote"),
2338 url.Values{"csrf": {csrf}, "version": {"0.7.0-1"}, "channel": {"stable"}, "confirmed": {"1"}})
2339
2340 if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "tagged 0.7.0") {
2341 t.Fatalf("promote-to-stable flash missing tag name:\n%s", body)
2342 }
2343
2344 postGated(t, fx, c, fx.u("/apps/kass/promote"),
2345 url.Values{"csrf": {csrf}, "version": {"0.7.0-1"}, "channel": {"stable"}, "confirmed": {"1"}})
2346 if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "already serves 0.7.0-1") || !strings.Contains(body, "tagged 0.7.0") {
2347 t.Fatalf("no-op stable promote flash missing tag name:\n%s", body)
2348 }
2349}
2350
2351// TestPromoteFlashNamesTagWarning is the other half of the binding
2352// constraint: a version that reaches stable but is NOT a "<target>-<n>"
2353// iteration (a sha build, an explicit -version ship) carries no tag —
2354// stableTag sets res.TagWarning instead of res.Tag (internal/release's
2355// release.go), and the flash must say why, in parentheses, exactly as
2356// tagSuffix builds it, not go silent about it.
2357func TestPromoteFlashNamesTagWarning(t *testing.T) {
2358 fx := newConsoleFixture(t)
2359 c := signInPaul(t, fx)
2360 csrf := sessionToken(t, fx, c)
2361
2362 shipVersion(t, fx, "kass", "v9")
2363 postForm(t, c, fx.u("/apps/kass/promote"),
2364 url.Values{"csrf": {csrf}, "version": {"v9"}, "new_channel": {"edge"}, "confirmed": {"1"}})
2365 postForm(t, c, fx.u("/apps/kass/promote"),
2366 url.Values{"csrf": {csrf}, "version": {"v9"}, "new_channel": {"beta"}, "confirmed": {"1"}})
2367 postGated(t, fx, c, fx.u("/apps/kass/promote"),
2368 url.Values{"csrf": {csrf}, "version": {"v9"}, "channel": {"stable"}, "confirmed": {"1"}})
2369
2370 _, _, body := get(t, c, fx.u("/apps/kass"))
2371 // html/template escapes the flash text, so the literal angle brackets in
2372 // TagWarning's "<target>-<n>" arrive as entities.
2373 if !strings.Contains(body, "(no tag cut: v9 is not a &lt;target&gt;-&lt;n&gt; iteration)") {
2374 t.Fatalf("promote-to-stable flash missing tag warning:\n%s", body)
2375 }
2376 if strings.Contains(body, " — tagged ") {
2377 t.Fatalf("flash claims a tag was cut for a non-iteration version:\n%s", body)
2378 }
2379}
2380
2381// TestReleasesPageNoTagsSection: an app with no tags cut renders 200 and
2382// omits the Tags section entirely — {{if .Tags}} must not print an empty
2383// heading, and a nil/empty p.Tags must not be confused with a read failure.
2384func TestReleasesPageNoTagsSection(t *testing.T) {
2385 fx := newConsoleFixture(t)
2386 c := signInPaul(t, fx)
2387
2388 status, _, body := get(t, c, fx.u("/apps/kass/releases"))
2389 if status != http.StatusOK {
2390 t.Fatalf("/apps/kass/releases = %d, want 200", status)
2391 }
2392 if strings.Contains(body, "<h2>Tags</h2>") {
2393 t.Fatalf("Tags section rendered with no tags ever cut:\n%s", body)
2394 }
2395}
2396
2397// newRedirectFixture builds the minimal Server the CONSOLE_REDIRECT_HOSTS
2398// tests need — same trimmed shape as newMetricsFixture (metricsview_test.go),
2399// with none of consoleFixture's registry/keymail/HTTP-listener scaffolding,
2400// but a caller-chosen Origin and RedirectHosts since that's what's under
2401// test here.
2402func newRedirectFixture(t *testing.T, origin string, redirectHosts []string) *Server {
2403 t.Helper()
2404 st, err := Open(filepath.Join(t.TempDir(), "console.db"))
2405 if err != nil {
2406 t.Fatal(err)
2407 }
2408 t.Cleanup(func() { st.Close() })
2409 rel := release.NewDirStore(filepath.Join(t.TempDir(), "bucket"))
2410 srv, err := NewServer(Config{Releases: rel, Origin: origin, RedirectHosts: redirectHosts}, st)
2411 if err != nil {
2412 t.Fatal(err)
2413 }
2414 return srv
2415}
2416
2417// serveRedirect drives srv.ServeHTTP directly with a chosen Host header — no
2418// httptest.Server/listener needed, since the redirect check reads r.Host
2419// before the mux (or anything network-shaped) is involved.
2420func serveRedirect(t *testing.T, srv *Server, method, host, target string) *httptest.ResponseRecorder {
2421 t.Helper()
2422 req := httptest.NewRequest(method, target, nil)
2423 req.Host = host
2424 rec := httptest.NewRecorder()
2425 srv.ServeHTTP(rec, req)
2426 return rec
2427}
2428
2429func TestRedirectHostsPermanentRedirect(t *testing.T) {
2430 srv := newRedirectFixture(t, "https://console.example.test", []string{"dashboard.example.test"})
2431
2432 rec := serveRedirect(t, srv, http.MethodGet, "dashboard.example.test", "/a/foo?x=1")
2433 if rec.Code != http.StatusMovedPermanently {
2434 t.Fatalf("GET on a redirect host = %d, want 301", rec.Code)
2435 }
2436 if loc := rec.Header().Get("Location"); loc != "https://console.example.test/a/foo?x=1" {
2437 t.Fatalf("Location = %q, want the canonical origin with the request untouched", loc)
2438 }
2439}
2440
2441// TestRedirectHostsPreservesMethodWith308 pins the 308 choice for anything
2442// other than GET/HEAD: a 301/302 lets browsers replay a POST as a GET and
2443// drop its body, which is exactly the client breakage RedirectHosts must not
2444// cause on a host still fielding real requests.
2445func TestRedirectHostsPreservesMethodWith308(t *testing.T) {
2446 srv := newRedirectFixture(t, "https://console.example.test", []string{"dashboard.example.test"})
2447
2448 rec := serveRedirect(t, srv, http.MethodPost, "dashboard.example.test", "/a/foo?x=1")
2449 if rec.Code != http.StatusPermanentRedirect {
2450 t.Fatalf("POST on a redirect host = %d, want 308 (keeps the method and body on replay)", rec.Code)
2451 }
2452 if loc := rec.Header().Get("Location"); loc != "https://console.example.test/a/foo?x=1" {
2453 t.Fatalf("Location = %q, want the canonical origin", loc)
2454 }
2455}
2456
2457func TestRedirectHostsMatchesHostWithPort(t *testing.T) {
2458 srv := newRedirectFixture(t, "https://console.example.test", []string{"dashboard.example.test"})
2459
2460 rec := serveRedirect(t, srv, http.MethodGet, "dashboard.example.test:443", "/a/foo?x=1")
2461 if rec.Code != http.StatusMovedPermanently {
2462 t.Fatalf("Host header carrying a port = %d, want 301 — the port must not defeat the match", rec.Code)
2463 }
2464 if loc := rec.Header().Get("Location"); loc != "https://console.example.test/a/foo?x=1" {
2465 t.Fatalf("Location = %q, want the canonical origin", loc)
2466 }
2467}
2468
2469// TestRedirectHostsLeavesOtherHostsAlone pins the opt-in contract: a host
2470// that was never listed (here, the console's own origin host) gets the
2471// route it would always get, not a redirect — this is what keeps
2472// platform.carlosframework.com serving CLI bearer tokens untouched.
2473func TestRedirectHostsLeavesOtherHostsAlone(t *testing.T) {
2474 srv := newRedirectFixture(t, "https://console.example.test", []string{"dashboard.example.test"})
2475
2476 rec := serveRedirect(t, srv, http.MethodGet, "console.example.test", "/healthz")
2477 if rec.Code == http.StatusMovedPermanently || rec.Code == http.StatusPermanentRedirect {
2478 t.Fatalf("an unlisted host was redirected: %d -> %q", rec.Code, rec.Header().Get("Location"))
2479 }
2480 if rec.Code != 200 || !strings.Contains(rec.Body.String(), "ok") {
2481 t.Fatalf("unlisted host = %d %q, want /healthz served normally", rec.Code, rec.Body.String())
2482 }
2483}
2484
2485// TestNewServerRefusesRedirectingItsOwnOrigin pins the loop guard: a
2486// RedirectHosts entry equal to Origin's own host would 301/308 a request
2487// straight back to the host that just answered it.
2488func TestNewServerRefusesRedirectingItsOwnOrigin(t *testing.T) {
2489 st, err := Open(filepath.Join(t.TempDir(), "console.db"))
2490 if err != nil {
2491 t.Fatal(err)
2492 }
2493 t.Cleanup(func() { st.Close() })
2494 rel := release.NewDirStore(filepath.Join(t.TempDir(), "bucket"))
2495 _, err = NewServer(Config{
2496 Releases: rel,
2497 Origin: "https://console.example.test",
2498 RedirectHosts: []string{"console.example.test"},
2499 }, st)
2500 if err == nil {
2501 t.Fatal("NewServer accepted a RedirectHosts entry equal to its own origin host — that config would redirect-loop")
2502 }
2503}
2504
2505// TestNewServerRefusesRedirectHostsWithoutAnOriginHost pins the other half of
2506// the loop guard: an origin it cannot read a host out of (here the classic
2507// operator slip, a CONSOLE_ORIGIN with no scheme) leaves the guard with
2508// nothing to compare against AND leaves every Location without a host of its
2509// own. Refuse at start rather than serve half a redirect.
2510func TestNewServerRefusesRedirectHostsWithoutAnOriginHost(t *testing.T) {
2511 for _, origin := range []string{"", "console.example.test"} {
2512 st, err := Open(filepath.Join(t.TempDir(), "console.db"))
2513 if err != nil {
2514 t.Fatal(err)
2515 }
2516 t.Cleanup(func() { st.Close() })
2517 rel := release.NewDirStore(filepath.Join(t.TempDir(), "bucket"))
2518 if _, err := NewServer(Config{
2519 Releases: rel,
2520 Origin: origin,
2521 RedirectHosts: []string{"dashboard.example.test"},
2522 }, st); err == nil {
2523 t.Errorf("NewServer accepted RedirectHosts with Origin %q — there is no host to redirect to", origin)
2524 }
2525 }
2526}
2527
2528// TestRedirectHostsRootlessTargetStaysOnTheOrigin pins the Location against a
2529// request target that isn't a path. Go parses `GET mailto:x@evil.com` into an
2530// opaque URL, and origin+RequestURI would then read
2531// https://console.example.test@evil.com — userinfo, not host — handing an
2532// outsider a redirect off the deployment with the console's name on the
2533// front of it.
2534func TestRedirectHostsRootlessTargetStaysOnTheOrigin(t *testing.T) {
2535 srv := newRedirectFixture(t, "https://console.example.test", []string{"dashboard.example.test"})
2536
2537 for _, target := range []string{"mailto:x@evil.com", "https:evil.com"} {
2538 rec := serveRedirect(t, srv, http.MethodGet, "dashboard.example.test", target)
2539 loc := rec.Header().Get("Location")
2540 if loc != "https://console.example.test/" {
2541 t.Errorf("target %q redirected to %q, want the origin's front door", target, loc)
2542 }
2543 }
2544}
2545
2546func TestSummarizeVerdictAndURL(t *testing.T) {
2547 now := time.Now()
2548 v := AppView{App: "kass",
2549 Routes: []RouteView{
2550 {Host: "kass.bab.oncarlos.com", Account: "bab", Kind: "instance", Status: "ready", Version: "d388035"},
2551 {Host: "kass.example.com", Account: "bab", Kind: "instance", Status: "ready", Version: "d388035"},
2552 },
2553 Channels: []ChannelView{
2554 {Name: "stable", Version: "d388035", PromotedAt: now.Add(-2 * time.Hour)},
2555 {Name: "canary/rc-1", Version: "aaa1111"},
2556 }}
2557 row := summarize(v, "bab")
2558 if row.URL != "kass.example.com" {
2559 t.Errorf("URL = %q, want the apex host kass.example.com", row.URL)
2560 }
2561 if row.Verdict != "ok" || row.VerdictLabel != "Deployed" {
2562 t.Errorf("verdict = %q/%q, want ok/Deployed", row.Verdict, row.VerdictLabel)
2563 }
2564 if row.CanaryN != 1 {
2565 t.Errorf("CanaryN = %d, want 1", row.CanaryN)
2566 }
2567 if row.Deployment.UniformVersion() != "d388035" {
2568 t.Errorf("observed version = %q, want d388035", row.Deployment.UniformVersion())
2569 }
2570
2571 v.Routes[0].Status = "failed"
2572 row = summarize(v, "bab")
2573 if row.Verdict != "attention" || row.VerdictLabel != "Needs attention" {
2574 t.Errorf("failed route: verdict = %q/%q, want attention/Needs attention", row.Verdict, row.VerdictLabel)
2575 }
2576
2577 empty := summarize(AppView{App: "new"}, "bab")
2578 if empty.Verdict != "notdeployed" || empty.VerdictLabel != "Not deployed yet" {
2579 t.Errorf("no stable: verdict = %q/%q, want notdeployed/Not deployed yet", empty.Verdict, empty.VerdictLabel)
2580 }
2581
2582 edgeOnly := summarize(AppView{App: "edge-only", Routes: []RouteView{
2583 {Host: "edge.example", Account: "bab", Channel: "edge", Version: "v9"},
2584 }}, "bab")
2585 if edgeOnly.Verdict != "ok" || edgeOnly.Deployment.UniformVersion() != "v9" {
2586 t.Errorf("edge-only route = %+v, want deployed at its observed v9", edgeOnly)
2587 }
2588
2589 mixed := summarize(AppView{App: "mixed", Routes: []RouteView{
2590 {Host: "a.example", Account: "bab", Channel: "production", Version: "v1"},
2591 {Host: "b.example", Account: "bab", Channel: "canary/live", Version: "v2"},
2592 }}, "bab")
2593 if mixed.Verdict != "ok" || !mixed.Deployment.Mixed() || mixed.Deployment.UniformVersion() != "" {
2594 t.Errorf("mixed deployment = %+v", mixed)
2595 }
2596
2597 partial := summarize(AppView{App: "partial", Routes: []RouteView{
2598 {Host: "a.example", Account: "bab", Version: "v1"},
2599 {Host: "b.example", Account: "bab"},
2600 }}, "bab")
2601 if partial.Verdict != "unknown" {
2602 t.Errorf("partial deployment verdict = %q, want unknown", partial.Verdict)
2603 }
2604
2605 obsolete := summarize(AppView{App: "obsolete", Channels: []ChannelView{{Name: "stable", Version: "old"}}}, "bab")
2606 if obsolete.Verdict != "notdeployed" || obsolete.Deployment.UniformVersion() != "" {
2607 t.Errorf("obsolete stable target = %+v, want not deployed", obsolete)
2608 }
2609
2610 bad := summarize(AppView{App: "kass", Routes: []RouteView{{Host: "x", Account: "bab"}}, Channels: []ChannelView{{Name: "stable", Err: "unreadable"}}}, "bab")
2611 if bad.Verdict != "unknown" || bad.VerdictLabel != "Can't tell right now" {
2612 t.Errorf("unknown route report: verdict = %q/%q, want unknown/Can't tell right now", bad.Verdict, bad.VerdictLabel)
2613 }
2614
2615 // A foreign account's route must not draw this row's URL or trip its
2616 // verdict — draws the tenant boundary summarize is supposed to enforce.
2617 foreign := AppView{App: "kass",
2618 Routes: []RouteView{
2619 {Host: "kass.bab.oncarlos.com", Account: "bab", Kind: "instance", Status: "ready", Version: "edge-v"},
2620 {Host: "a.a", Account: "other", Kind: "instance", Status: "failed"},
2621 },
2622 Channels: []ChannelView{
2623 {Name: "stable", Version: "d388035", PromotedAt: now.Add(-2 * time.Hour)},
2624 }}
2625 row = summarize(foreign, "bab")
2626 if row.Verdict != "ok" || row.VerdictLabel != "Deployed" {
2627 t.Errorf("foreign failed route leaked into verdict: %q/%q, want ok/Deployed", row.Verdict, row.VerdictLabel)
2628 }
2629 if row.URL != "kass.bab.oncarlos.com" {
2630 t.Errorf("URL = %q, want the own-account host kass.bab.oncarlos.com, not the foreign apex-ier a.a", row.URL)
2631 }
2632}
2633
2634func TestNormalizeHost(t *testing.T) {
2635 for _, tc := range []struct{ in, want string }{
2636 {"dashboard.example.test", "dashboard.example.test"},
2637 {"DASHBOARD.Example.Test:443", "dashboard.example.test"},
2638 {" dashboard.example.test ", "dashboard.example.test"},
2639 // An IPv6 literal keys the same with or without its port, which is
2640 // only true if the portless form loses its brackets too.
2641 {"[::1]", "::1"},
2642 {"[::1]:8080", "::1"},
2643 } {
2644 if got := normalizeHost(tc.in); got != tc.want {
2645 t.Errorf("normalizeHost(%q) = %q, want %q", tc.in, got, tc.want)
2646 }
2647 }
2648}
2649
2650// A deployment that sets CARLOS_CONSOLE_NAME reads as itself everywhere a
2651// person looks: the front page, the tab title, the header and the new
2652// account's welcome. Unset, the CARLOS names stay (TestEmptyStateWelcomesNewUser).
2653func TestConsoleNameBrandsThePages(t *testing.T) {
2654 fx := newConsoleFixture(t)
2655 fx.srv.consoleNameCfg = "Carloku"
2656 _, _, front := get(t, newBrowser(t), fx.ts.URL+"/")
2657 for _, want := range []string{"🤖 Carloku", "· Carloku</title>", `<a href="/">Carloku</a>`} {
2658 if !strings.Contains(front, want) {
2659 t.Fatalf("front page missing %q", want)
2660 }
2661 }
2662 if strings.Contains(front, "CARLOS console") {
2663 t.Fatal("front page still says CARLOS console")
2664 }
2665 fx.setUser("bob@keymail.dev")
2666 c := newBrowser(t)
2667 signUp(t, fx, c, "code-b")
2668 _, home, _ := get(t, c, fx.ts.URL+"/")
2669 _, _, body := get(t, c, fx.ts.URL+home)
2670 // Inside an account the header is the account switcher, not the brand.
2671 for _, want := range []string{"Welcome to Carloku", "· Carloku</title>"} {
2672 if !strings.Contains(body, want) {
2673 t.Fatalf("account home missing %q", want)
2674 }
2675 }
2676 if strings.Contains(body, "CARLOS platform") || strings.Contains(body, "CARLOS console") {
2677 t.Fatal("account home still names CARLOS")
2678 }
2679}
2680