package console import ( "context" "crypto/ed25519" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "os" "path/filepath" "slices" "strings" "testing" "time" "github.com/carlosframework/platform/internal/metrics" "github.com/carlosframework/platform/internal/perf" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/s3fake" "github.com/carlosframework/platform/internal/status" "github.com/carlosframework/platform/internal/version" ) // consoleFixture is a whole console: fixture platform (registry + bucket // from view_test.go), console store, fake keymail, and the server under an // httptest listener whose URL is its own OAuth origin. type consoleFixture struct { ts *httptest.Server srv *Server st *Store fk *fakeKeymail rel release.Store regPath string priv ed25519.PrivateKey pub ed25519.PublicKey sqid string // the fixture account's public identity } func newConsoleFixture(t *testing.T) *consoleFixture { return newConsoleFixtureKeyed(t, true) } // newConsoleFixtureKeyed builds the fixture with or without the channel // signing key (withKey=false is the read-only deployment shape). func newConsoleFixtureKeyed(t *testing.T, withKey bool) *consoleFixture { return newConsoleFixtureOn(t, withKey, nil) } // newConsoleFixtureOn is newConsoleFixtureKeyed over rel, when rel is not // nil, instead of the fixture's seeded DirStore. func newConsoleFixtureOn(t *testing.T, withKey bool, rel release.Store) *consoleFixture { t.Helper() return newConsoleFixtureWith(t, consoleFixtureOptions{WithKey: withKey, Releases: rel}) } // consoleFixtureOptions are newConsoleFixtureWith's choices. The zero // value is newConsoleFixtureKeyed(t, false). type consoleFixtureOptions struct { // WithKey gives the console the channel signing key; false is the // read-only deployment shape. WithKey bool // Releases, when not nil, replaces the fixture's seeded DirStore. A // test that needs a real S3 store (TestDBForwardMinio) passes its own; // the fixture's seeded releases are then absent, which those tests do // not read. Not with S3. Releases release.Store // WrapReleases, when set, wraps the store NewServer is given (after S3 // is applied). fx.rel stays the unwrapped store. WrapReleases func(release.Store) release.Store // AppClient is Config.AppClient, the app-host client (nil: NewServer's). AppClient *http.Client // S3, when set, puts the console on a real release.S3Store talking to // an s3fake over the fixture's seeded DirStore, and attaches the fake // to S3. Seeding and fx.rel stay on the DirStore, so they are free and // uncounted. The store is NewDeploymentS3Store's (change index on, as // production), configured through the environment with t.Setenv, so // such a test is never parallel; its client is timed by TimeS3Store // into the server's own recorder (Config.Perf). S3 *perfLatency // Wrap, when set, wraps the console's handler under the httptest // listener: the perf walk's in-flight counter, which must see each // handler return before the next request is timed. Wrap func(http.Handler) http.Handler } // newConsoleFixtureWith builds the fixture o describes. func newConsoleFixtureWith(t *testing.T, o consoleFixtureOptions) *consoleFixture { t.Helper() regPath, relStore, priv, pub := buildFixtures(t) if o.Releases != nil { if o.S3 != nil { t.Fatal("newConsoleFixtureWith: Releases and S3 are exclusive") } relStore = o.Releases } served := relStore var rec *perf.Recorder if o.S3 != nil { rec = new(perf.Recorder) served = fixtureS3Store(t, relStore, o.S3, rec) } if o.WrapReleases != nil { served = o.WrapReleases(served) } st, err := Open(filepath.Join(t.TempDir(), "console.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) fk := newFakeKeymail(t, "paul@keymail.dev") // The origin must equal the listener's URL, which only exists after the // listener does — hence the indirection. var srv *Server var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.ServeHTTP(w, r) }) if o.Wrap != nil { h = o.Wrap(h) } ts := httptest.NewServer(h) t.Cleanup(ts.Close) cfg := Config{ Origin: ts.URL, Keymail: fk.srv.URL, Registry: regPath, Releases: served, ReleasePub: pub, AppScheme: "http", // instance polls reach httptest apps, not TLS AppClient: o.AppClient, Perf: rec, } if o.WithKey { cfg.Key = priv } srv, err = NewServer(cfg, st) if err != nil { t.Fatal(err) } // Fixture route hosts don't resolve; keep the not-reporting verdict quick. srv.instances.timeout = 100 * time.Millisecond // paul owns the built-in ops account (created by NewServer's backfill), // which claims kass — the shape a deployment is in once the pre-accounts // grants have been mapped onto memberships. if err := st.AddMember(OpsAccountID, "paul@keymail.dev", RoleOwner, time.Now()); err != nil { t.Fatal(err) } if _, err := st.CreateAppIn(OpsAccountID, "kass", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } return &consoleFixture{ts: ts, srv: srv, st: st, fk: fk, rel: relStore, regPath: regPath, priv: priv, pub: pub, sqid: release.OpsAccount} } // fixtureS3Store starts an s3fake over seeded's DirStore, attaches it to // lat, and returns the production deployment store pointed at it, timed // into rec. func fixtureS3Store(t *testing.T, seeded release.Store, lat *perfLatency, rec *perf.Recorder) release.Store { t.Helper() ds, ok := seeded.(*deletableStore) if !ok { t.Fatalf("fixture store is %T, want the seeded *deletableStore", seeded) } dir, ok := ds.Store.(*release.DirStore) if !ok { t.Fatalf("fixture store wraps %T, want *release.DirStore", ds.Store) } lat.fake = s3fake.New(dir) lat.fake.ListFrom(ds.root) // list from the directory, not by walking the whole bucket each time lat.Enable(lat.on.Load()) // apply the delay to the fake already attached fake := httptest.NewServer(lat.fake) t.Cleanup(fake.Close) t.Setenv("CARLOS_S3_ENDPOINT", fake.URL) t.Setenv("AWS_ACCESS_KEY_ID", "fixture") t.Setenv("AWS_SECRET_ACCESS_KEY", "fixture") t.Setenv("AWS_REGION", "us-east-1") t.Setenv("AWS_EC2_METADATA_DISABLED", "true") s, err := release.NewDeploymentS3Store(context.Background(), "carlos-fixture") if err != nil { t.Fatal(err) } return TimeS3Store(s, rec) } // path is a path under the fixture's account, and u is its full URL — every // app page and mutation lives under /a// now. func (fx *consoleFixture) path(p string) string { return "/a/" + fx.sqid + p } func (fx *consoleFixture) u(p string) string { return fx.ts.URL + fx.path(p) } // channelVersion reads a channel pointer straight from the bucket. func (fx *consoleFixture) channelVersion(t *testing.T, app, channel string) *release.ChannelPointer { t.Helper() p, err := release.Channel(context.Background(), fx.rel, fx.pub, release.OpsAccount, app, channel) if err != nil { t.Fatalf("channel %s/%s: %v", app, channel, err) } return p } func (fx *consoleFixture) setUser(address string) { fx.fk.mu.Lock() defer fx.fk.mu.Unlock() fx.fk.address = address } // newBrowser is a cookie-jarred client that never follows redirects, so // every hop is assertable. func newBrowser(t *testing.T) *http.Client { t.Helper() jar, err := cookiejar.New(nil) if err != nil { t.Fatal(err) } return &http.Client{ Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } } func get(t *testing.T, c *http.Client, u string) (status int, location, body string) { t.Helper() res, err := c.Get(u) if err != nil { t.Fatal(err) } defer res.Body.Close() raw, err := io.ReadAll(res.Body) if err != nil { t.Fatal(err) } return res.StatusCode, res.Header.Get("Location"), string(raw) } func postForm(t *testing.T, c *http.Client, u string, form url.Values) (status int, location, body string) { t.Helper() res, err := c.PostForm(u, form) if err != nil { t.Fatal(err) } defer res.Body.Close() raw, err := io.ReadAll(res.Body) if err != nil { t.Fatal(err) } return res.StatusCode, res.Header.Get("Location"), string(raw) } // signUp walks the whole OAuth ceremony: /auth/signin's redirect is parsed // for state + PKCE challenge (handed to the fake, which verifies the // verifier at exchange), then the callback lands with a code. func signUp(t *testing.T, fx *consoleFixture, c *http.Client, code string) { t.Helper() status, loc, _ := get(t, c, fx.ts.URL+"/auth/signin") if status != http.StatusSeeOther { t.Fatalf("signin = %d, want 303", status) } u, err := url.Parse(loc) if err != nil { t.Fatal(err) } km, _ := url.Parse(fx.fk.srv.URL) if u.Host != km.Host || u.Path != "/oauth/authorize" { t.Fatalf("authorize redirect went to %v, want %s/oauth/authorize", u, km.Host) } q := u.Query() if q.Get("client_id") != fx.ts.URL || q.Get("redirect_uri") != fx.ts.URL+"/auth/callback" { t.Fatalf("authorize params = %v", q) } if q.Get("scope") != "identify send" || q.Get("code_challenge_method") != "S256" { t.Fatalf("authorize params = %v", q) } fx.fk.mu.Lock() fx.fk.challenge = q.Get("code_challenge") fx.fk.expectCode = code fx.fk.mu.Unlock() status, loc, _ = get(t, c, fx.ts.URL+"/auth/callback?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(q.Get("state"))) // Signing in lands you in an account — the MRU one, your first membership, // or one minted on the spot. if status != http.StatusSeeOther || !strings.HasPrefix(loc, "/a/") { t.Fatalf("callback = %d -> %q, want 303 into an account", status, loc) } } func sessionToken(t *testing.T, fx *consoleFixture, c *http.Client) string { t.Helper() u, _ := url.Parse(fx.ts.URL) for _, ck := range c.Jar.Cookies(u) { if ck.Name == sessionCookie { return ck.Value } } t.Fatal("no session cookie in jar") return "" } func TestConsoleE2E(t *testing.T) { fx := newConsoleFixture(t) c := newBrowser(t) if status, _, body := get(t, c, fx.ts.URL+"/healthz"); status != 200 || !strings.Contains(body, "ok") { t.Fatalf("healthz = %d %q", status, body) } if status, loc, _ := get(t, c, fx.u("/")); status != http.StatusFound || loc != "/" { t.Fatalf("signed-out /apps = %d -> %q, want 302 -> /", status, loc) } if _, _, body := get(t, c, fx.ts.URL+"/"); !strings.Contains(body, `action="/auth/continue"`) { t.Fatal("landing page is missing the sign-in form") } signUp(t, fx, c, "code-1") // First view: kass listed (and only kass), with the account-created flash. _, _, body := get(t, c, fx.u("/")) // The account home list shows the app, its apex host, a health verdict // and the live version — not per-channel names/versions (that detail // moved to the app's own Releases tab, asserted below). for _, want := range []string{"Account created", "paul@keymail.dev", "kass", "v1", "Deployed", "1 test version"} { if !strings.Contains(body, want) { t.Fatalf("/apps missing %q in:\n%s", want, body) } } if strings.Contains(body, "otherapp") { t.Fatal("/apps lists an app the user does not own") } // The flash is one-shot. if _, _, body := get(t, c, fx.u("/")); strings.Contains(body, "Account created") { t.Fatal("welcome flash survived a second page view") } // Bake estimates only render for an app the production flag marks on // (2026-08-04 console bake controls spec §7) — seed it so the "bakes // until ~" assertion below still exercises the baking path rather than // the new "nothing bakes without the flag" zeroing. if err := release.SetProduction(context.Background(), fx.rel, fx.priv, release.OpsAccount, "kass", true, "test"); err != nil { t.Fatal(err) } // The ladder, with channel names and versions on it, now lives on the // Releases tab rather than the Overview page. status, _, body := get(t, c, fx.u("/apps/kass/releases")) if status != 200 { t.Fatalf("/apps/kass/releases = %d", status) } for _, want := range []string{"v1", "stable", "canary/x", "v2"} { if !strings.Contains(body, want) { t.Fatalf("/apps/kass/releases missing %q in:\n%s", want, body) } } // Releases tab: the Channels table, with the go-live estimate labeled. status, _, rbody := get(t, c, fx.u("/apps/kass/releases")) if status != 200 { t.Fatalf("/apps/kass/releases = %d", status) } for _, want := range []string{"canary/x", "v2", "in ~", "live now", "estimates"} { if !strings.Contains(rbody, want) { t.Fatalf("/apps/kass/releases missing %q in:\n%s", want, rbody) } } // Settings tab: the app's domains. status, _, sbody := get(t, c, fx.u("/apps/kass/settings/domains")) if status != 200 { t.Fatalf("/apps/kass/settings = %d", status) } for _, want := range []string{"Domains", "kass.carlosapp.dev", "docs.kass.carlosapp.dev", "custom", "ready"} { if !strings.Contains(sbody, want) { t.Fatalf("/apps/kass/settings missing %q in:\n%s", want, sbody) } } // Apps not owned 404 exactly like apps that don't exist. if status, _, _ := get(t, c, fx.u("/apps/otherapp")); status != http.StatusNotFound { t.Fatalf("/apps/otherapp = %d, want 404", status) } if status, _, _ := get(t, c, fx.u("/apps/nosuchapp")); status != http.StatusNotFound { t.Fatalf("/apps/nosuchapp = %d, want 404", status) } // Signout: wrong CSRF refused, right CSRF signs out. raw := sessionToken(t, fx, c) if status, _, _ := postForm(t, c, fx.ts.URL+"/auth/signout", url.Values{"csrf": {"wrong"}}); status != http.StatusForbidden { t.Fatalf("signout with bad csrf = %d, want 403", status) } if status, loc, _ := postForm(t, c, fx.ts.URL+"/auth/signout", url.Values{"csrf": {raw}}); status != http.StatusSeeOther || loc != "/" { t.Fatalf("signout = %d -> %q, want 303 -> /", status, loc) } if status, _, _ := get(t, c, fx.u("/")); status != http.StatusFound { t.Fatalf("post-signout /apps = %d, want redirect", status) } } // TestHealthzCarriesTheBuild: the console's own build, from the PROCESS. // // This is the deploy watch's exit criterion, and it exists because both cheap // answers lie. X-Carlos-Version is stamped by the EDGE from the route row // (edge.go:1336), so adoption sets it while the old process keeps serving; // `systemctl is-active` says "active" for a process started from the previous // version just as happily (tito, 2026-08-09, seconds after adopt and restart // both reported success). Only the running process can answer this. func TestHealthzCarriesTheBuild(t *testing.T) { fx := newConsoleFixture(t) status, _, body := get(t, &http.Client{}, fx.ts.URL+"/healthz") if status != 200 { t.Fatalf("/healthz = %d, want 200", status) } if !strings.Contains(body, "ok") { t.Fatalf("/healthz body = %q, want it to still start with ok (vet and the edge probe read this)", body) } fields := strings.Fields(body) if len(fields) != 2 || fields[1] != version.Build { t.Fatalf("/healthz body = %q, want exactly \"ok %s\" — the watch matches a whitespace-delimited token", body, version.Build) } } func TestSecondOAuthIsPlainSignIn(t *testing.T) { fx := newConsoleFixture(t) c1 := newBrowser(t) signUp(t, fx, c1, "code-1") get(t, c1, fx.u("/")) // consume the flash c2 := newBrowser(t) signUp(t, fx, c2, "code-2") // same address, fresh ceremony: no re-signup if _, _, body := get(t, c2, fx.u("/")); strings.Contains(body, "Account created") { t.Fatal("second OAuth for a known address flashed account-created") } fx.fk.mu.Lock() defer fx.fk.mu.Unlock() if fx.fk.exchanges != 2 { t.Fatalf("exchanges = %d, want 2", fx.fk.exchanges) } } func TestEmptyStateWelcomesNewUser(t *testing.T) { fx := newConsoleFixture(t) fx.setUser("bob@keymail.dev") // no memberships for bob c := newBrowser(t) signUp(t, fx, c, "code-b") // bob has no memberships, so signing in mints him a personal account and // lands him in it — the empty state is that account's, not paul's. _, home, _ := get(t, c, fx.ts.URL+"/") status, _, body := get(t, c, fx.ts.URL+home) if status != 200 { t.Fatalf("account home = %d", status) } for _, want := range []string{ "Account created", "Welcome to the CARLOS platform", "bob@keymail.dev", "Create app", `href="` + strings.TrimSuffix(home, "/") + `/apps/new"`, } { if !strings.Contains(body, want) { t.Fatalf("empty state missing %q in:\n%s", want, body) } } if strings.Contains(body, "kass") || strings.Contains(body, "otherapp") { t.Fatal("empty state leaked someone else's apps") } } // signInPaul signs a fresh browser in as the fixture's kass owner. func signInPaul(t *testing.T, fx *consoleFixture) *http.Client { t.Helper() c := newBrowser(t) signUp(t, fx, c, "code-"+t.Name()) return c } func TestCreateApp(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) // Happy path: claim, own, land on the ship-your-first-release panel. status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"zephyr"}}) if status != http.StatusSeeOther || loc != fx.path("/apps/zephyr") { t.Fatalf("claim = %d -> %q, want 303 -> /apps/zephyr", status, loc) } status, _, body := get(t, c, fx.u("/apps/zephyr")) if status != 200 { t.Fatalf("/apps/zephyr = %d", status) } for _, want := range []string{"Created app zephyr", "nothing shipped yet"} { if !strings.Contains(body, want) { t.Fatalf("/apps/zephyr missing %q in:\n%s", want, body) } } status, _, rbody := get(t, c, fx.u("/apps/zephyr/releases")) if status != 200 { t.Fatalf("/apps/zephyr/releases = %d", status) } for _, want := range []string{ "Nothing shipped yet", "carlos ship --app zephyr --version $(git rev-parse --short HEAD) --target linux-arm64", "carlos ship --app zephyr --kind static --version v1", "promoted to channels from this page", } { if !strings.Contains(rbody, want) { t.Fatalf("/apps/zephyr/releases missing %q in:\n%s", want, rbody) } } apps, err := fx.st.AppsIn(OpsAccountID) if err != nil || !strings.Contains(strings.Join(apps, " "), "zephyr") { t.Fatalf("AppsIn = %v %v, want zephyr claimed in the account", apps, err) } // Taken is taken WITHIN THE ACCOUNT: the name just claimed and a name the // pre-accounts grants hold are both "taken". (Another ACCOUNT claiming the // same name is not a conflict; that's TestSameAppNameInTwoAccounts.) // // "orphan" — releases already in the bucket under THIS account's own // prefix and no row — is no longer among them: since #79 an OWNER may // claim such a name, because those objects are the account's own release // history rather than a stranger's (apps_claim_test.go). if err := release.Ship(context.Background(), fx.rel, release.OpsAccount, release.Manifest{ Schema: 1, App: "orphan", Version: "v1", Kind: "binary", Artifacts: map[string]release.Artifact{"linux-amd64": {File: "orphan"}}, }, map[string][]byte{"linux-amd64": []byte("bin")}); err != nil { t.Fatal(err) } if err := fx.st.Grant("cli@keymail.dev", "cligranted"); err != nil { t.Fatal(err) } for _, name := range []string{"zephyr", "kass", "cligranted"} { if status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {name}}); status != http.StatusSeeOther || loc != fx.path("/apps/new") { t.Fatalf("claim %s = %d -> %q, want 303 -> the create form", name, status, loc) } if _, _, body := get(t, c, fx.u("/apps/new")); !strings.Contains(body, "That name is taken.") { t.Fatalf("claim %s: no taken flash", name) } } // The #79 exemption, through the browser door: paul owns this account, so // the orphaned bucket objects under apps/bab/orphan/ are his to adopt and // the claim lands on the app page like any other. if status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"orphan"}}); status != http.StatusSeeOther || loc != fx.path("/apps/orphan") { t.Fatalf("owner claim of orphan = %d -> %q, want 303 -> /apps/orphan (#79)", status, loc) } // Invalid names refused; CSRF enforced. if status, _, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"Bad Name!"}}); status != http.StatusSeeOther { t.Fatalf("invalid name = %d, want redirect with flash", status) } if _, _, body := get(t, c, fx.u("/apps/new")); !strings.Contains(body, "Invalid app name") { t.Fatal("no invalid-name flash") } if status, _, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {"wrong"}, "name": {"fine-name"}}); status != http.StatusForbidden { t.Fatalf("bad csrf claim = %d, want 403", status) } } func TestAppTabBar(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) _, home, _ := get(t, c, fx.ts.URL+"/") base := strings.TrimSuffix(home, "/") + "/apps/kass" _, _, body := get(t, c, fx.ts.URL+base) for _, want := range []string{ `class="tabs"`, `href="` + base + `"`, `href="` + base + `/releases"`, `href="` + base + `/instances"`, `href="` + base + `/activity"`, `href="` + base + `/settings"`, `aria-current="page">Overview`, } { if !strings.Contains(body, want) { t.Fatalf("app page missing %q", want) } } } func TestOverviewIsALadderNotAnEverythingPage(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) _, _, body := get(t, c, fx.u("/apps/kass")) // The redesign moved the ladder itself, and the full listing sections it // used to sit beside (Channels, Canaries, the Releases table), off // Overview entirely — it is now a stats dashboard of tiles, not a second // copy of the Releases tab. for _, gone := range []string{`class="ladder"`, "Channels", "

Canaries

"} { if strings.Contains(body, gone) { t.Fatalf("overview still renders %q", gone) } } for _, want := range []string{`class="tiles stat-tiles"`, "Running", "Web addresses", `/apps/kass/releases`} { if !strings.Contains(body, want) { t.Fatalf("overview missing %q", want) } } assertNonMember404(t, fx, "/apps/kass") } func TestReleasesTab(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) status, _, body := get(t, c, fx.u("/apps/kass/releases")) if status != 200 { t.Fatalf("GET releases tab = %d", status) } for _, want := range []string{ `/apps/kass/deployments" aria-current="page">`, "

Channels

", "

Canaries

", "All versions (", } { if !strings.Contains(body, want) { t.Fatalf("releases tab missing %q", want) } } // Every mutation form on the tab carries a back=releases hidden field, so // its redirect returns here rather than to the overview. The fixture's // count of 4 is 2 promote forms (one per version, v1 and v2) + 1 create- // canary form + 1 retire-canary form (canary/x) — the rollback form // contributes 0 because fixture stable is v1 with no history yet. if n := strings.Count(body, `name="back" value="releases"`); n != 4 { t.Fatalf(`releases tab has %d back=releases fields, want exactly 4`, n) } assertNonMember404(t, fx, "/apps/kass/releases") } // TestReleaseSummaryStripRenders pins the Make-live strip's form (M6, final // review): an owner with beta ahead of stable gets a real promote form // (channel=stable, version=beta's), a non-owner member sees no form at all — // just the "Only an account owner can make it live." note, since the fixture // is keyed and CanPromote is true either way. func TestReleaseSummaryStripRenders(t *testing.T) { fx := newConsoleFixture(t) shipVersion(t, fx, "kass", "v3") seedChannel(t, fx, "kass", "beta", "v3") c := signInPaul(t, fx) _, _, body := get(t, c, fx.u("/apps/kass/releases")) // The hidden channel=stable input is unique to the make-live strip's form // (the per-row promote forms elsewhere on this tab use a `) { t.Fatal("owner view missing the make-live promote form's channel=stable input") } if !strings.Contains(body, ``) { t.Fatalf("make-live form missing its version=v3 hidden input:\n%s", body) } if !strings.Contains(body, ``) { t.Fatal("member view rendered the owner-only make-live form") } if !strings.Contains(mBody, "Only an account owner can make it live.") { t.Fatal("member view missing the owner-only note") } } // TestFirstPromoteChannelOptions (I4, final review): a fresh app has shipped // a release but has zero channels yet — the per-row promote select must // still offer the ladder rungs, so the first promotion has somewhere to go. func TestFirstPromoteChannelOptions(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "freshapp", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } shipVersion(t, fx, "freshapp", "v1") c := signInPaul(t, fx) _, _, body := get(t, c, fx.u("/apps/freshapp/releases")) want := `` if !strings.Contains(body, want) { t.Fatalf("promote select missing the ladder-rung options in order:\n%s", body) } // The now-true copy this union restores: still says "ship a release, then // promote it below" even though there IS a release, because the promote // control the reader needs is right there in the versions table. if !strings.Contains(body, "No channels yet") { t.Fatal("zero-channel app missing the empty Channels-table copy") } } // TestInstancesEmptyStateHintsStable (I2, final review): the Running tab's // empty state must steer a member at "give this a URL" onto stable, not // canary/ — a canary is a private test version, the wrong first door // for "how do I run this at all". func TestInstancesEmptyStateHintsStable(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "routeless", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } c := signInPaul(t, fx) _, _, body := get(t, c, fx.u("/apps/routeless/instances")) if !strings.Contains(body, "--channel stable") { t.Fatalf("routeless app's Running empty state missing --channel stable:\n%s", body) } if strings.Contains(body, "canary/") { t.Fatalf("routeless app's Running empty state still points at a canary channel:\n%s", body) } } func TestInstancesTab(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) status, _, body := get(t, c, fx.u("/apps/kass/instances")) if status != 200 { t.Fatalf("GET instances tab = %d", status) } if !strings.Contains(body, `aria-current="page">Running`) { t.Fatal("tab not active") } assertNonMember404(t, fx, "/apps/kass/instances") } // assertNonMember404 signs bob (no membership in the fixture account) in and // asserts the account-scoped path answers him byte-identically to an account // that does not exist at all. func assertNonMember404(t *testing.T, fx *consoleFixture, sub string) { t.Helper() fx.setUser("bob@keymail.dev") c := newBrowser(t) signUp(t, fx, c, "code-nm-"+t.Name()) s1, _, b1 := get(t, c, fx.u(sub)) s2, _, b2 := get(t, c, fx.ts.URL+"/a/zzzzzzzz"+sub) if s1 != http.StatusNotFound || s2 != http.StatusNotFound || b1 != b2 { t.Fatalf("non-member leak on %s: member-URL=%d unknown-URL=%d identical=%v", sub, s1, s2, b1 == b2) } } func TestCreateAppPage(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) status, _, body := get(t, c, fx.u("/apps/new")) if status != 200 { t.Fatalf("GET /apps/new = %d", status) } for _, want := range []string{ "Create an app", `action="` + fx.path("/apps/new") + `"`, "lowercase letters, digits, and dashes", } { if !strings.Contains(body, want) { t.Fatalf("create-app page missing %q", want) } } // The account home now links here instead of embedding the form. _, _, home := get(t, c, fx.u("/")) if !strings.Contains(home, `/apps/new"`) { t.Fatal("account home does not link to /apps/new") } if strings.Contains(home, `action="`+fx.path("/apps/new")+`"`) { t.Fatal("account home still embeds the create form") } assertNonMember404(t, fx, "/apps/new") } func TestTeamPage(t *testing.T) { fx := newConsoleFixture(t) if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } tok, err := fx.st.CreateInvite(OpsAccountID, "sam@keymail.dev", "paul@keymail.dev", time.Now()) if err != nil { t.Fatal(err) } c := signInPaul(t, fx) status, _, body := get(t, c, fx.u("/team")) if status != 200 { t.Fatalf("GET /team = %d", status) } for _, want := range []string{ "Team", "people who can see and ship", `action="` + fx.path("/invite") + `"`, esc(copyText("team.confirm_note")), } { if !strings.Contains(body, want) { t.Fatalf("team page missing %q", want) } } // The account home no longer carries the member machinery. _, _, home := get(t, c, fx.u("/")) for _, gone := range []string{"Invite a teammate", "Hand over ownership"} { if strings.Contains(home, gone) { t.Fatalf("account home still shows %q", gone) } } assertNonMember404(t, fx, "/team") // A non-owner member sees the roster and nothing an owner alone should // hold: no invite form, no member-administration forms, and — the one // that matters — not the pending invite's TOKEN. That token is a bearer // capability (whoever holds it can accept the invite as its addressee), // so PendingInvites must stay behind the role==RoleOwner branch, not just // behind membership. fx.setUser("kim@keymail.dev") member := newBrowser(t) signUp(t, fx, member, "code-kim-team") mStatus, _, mBody := get(t, member, fx.u("/team")) if mStatus != 200 { t.Fatalf("member GET /team = %d", mStatus) } if strings.Contains(mBody, tok) { t.Fatal("member view leaked a pending invite's token") } for _, gone := range []string{"Invite a teammate", "members/remove", "members/transfer"} { if strings.Contains(mBody, gone) { t.Fatalf("member view of /team still shows %q", gone) } } } func TestAccountSettingsPage(t *testing.T) { fx := newConsoleFixture(t) if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } c := signInPaul(t, fx) status, _, body := get(t, c, fx.u("/settings")) if status != 200 { t.Fatalf("GET /settings = %d", status) } for _, want := range []string{"Account settings", "

Identity

", "Your role"} { if !strings.Contains(body, want) { t.Fatalf("settings page missing %q", want) } } // The fixture account has apps (kass), so no delete form may render — // but the section explains itself instead of vanishing silently. if strings.Contains(body, `action="`+fx.path("/delete")+`"`) { t.Fatal("delete form rendered for an account with apps") } if !strings.Contains(body, "can't be deleted") { t.Fatal("missing the why-not-deletable explanation") } assertNonMember404(t, fx, "/settings") // A non-owner member sees the Identity section and nothing of the danger // zone at all — not the heading, not the form, not the explanation. The // section is owner-only display, same as Team's member-administration // forms; the account having apps here is incidental, not the reason. fx.setUser("kim@keymail.dev") member := newBrowser(t) signUp(t, fx, member, "code-kim-settings") mStatus, _, mBody := get(t, member, fx.u("/settings")) if mStatus != 200 { t.Fatalf("member GET /settings = %d", mStatus) } if !strings.Contains(mBody, "

Identity

") { t.Fatal("member view missing the Identity section") } for _, gone := range []string{"Delete this account", `action="` + fx.path("/delete") + `"`} { if strings.Contains(mBody, gone) { t.Fatalf("member view of /settings still shows %q", gone) } } } func TestAppNamedNewIsRefused(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"new"}}) if status != http.StatusSeeOther || loc != fx.path("/apps/new") { t.Fatalf("claiming 'new' = %d -> %q, want 303 back to the form", status, loc) } if apps, _ := fx.st.AppsIn(OpsAccountID); slices.Contains(apps, "new") { t.Fatal("reserved name 'new' was claimed") } } func TestPromoteFlow(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) // The releases tab offers promote forms per shipped version. _, _, body := get(t, c, fx.u("/apps/kass/releases")) if !strings.Contains(body, `action="/a/bab/apps/kass/promote"`) { t.Fatal("no promote form on the releases tab") } if strings.Contains(body, "Promotions disabled") { t.Fatal("keyed fixture rendered the disabled note") } // A build on no rung yet enters the ladder at edge, and the free-text // field is what creates that channel the first time. (The fixture's own // builds cannot move: v1 is already on stable and v2 is a canary, and the // ladder promotes neither — see TestLadderRefusesASkip.) shipVersion(t, fx, "kass", "v3") status, loc, _ := postForm(t, c, fx.u("/apps/kass/promote"), url.Values{"csrf": {csrf}, "version": {"v3"}, "channel": {"stable"}, "new_channel": {"edge"}, "confirmed": {"1"}}) if status != http.StatusSeeOther || loc != fx.path("/apps/kass") { t.Fatalf("promote = %d -> %q", status, loc) } if p := fx.channelVersion(t, "kass", "edge"); p.Version != "v3" { t.Fatalf("edge pointer = %+v, want v3", p) } _, _, body = get(t, c, fx.u("/apps/kass")) // edge holds zero — pipelines spec: merged to main is picked up as it lands. // The flash for an edge promotion contains only "Promoted v3 to edge." without // the "bake window" text that a held channel gets on a PRODUCTION app. if !strings.Contains(body, ``) { t.Fatalf("edge promotion flash incorrect:\n%s", body) } // One rung at a time, up to stable — which is the rung that asks for a // passkey, so postGated walks the confirmation the console demands. postForm(t, c, fx.u("/apps/kass/promote"), url.Values{"csrf": {csrf}, "version": {"v3"}, "new_channel": {"beta"}, "confirmed": {"1"}}) if p := fx.channelVersion(t, "kass", "beta"); p.Version != "v3" { t.Fatalf("beta pointer = %+v, want v3", p) } // beta HOLDS, but this app has no production flag — the default is not // production (bake controls spec §2), so its boxes adopt beta immediately // and the flash must not promise a window. The exact banner is asserted, // not a substring: "no bake note here" is the property under test. if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, ``) { t.Fatalf("beta promotion flash incorrect (a flagless app must not be promised a bake window):\n%s", body) } postGated(t, fx, c, fx.u("/apps/kass/promote"), url.Values{"csrf": {csrf}, "version": {"v3"}, "channel": {"stable"}, "confirmed": {"1"}}) if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v3" || len(p.History) == 0 || p.History[0] != "v1" { t.Fatalf("stable pointer = %+v, want v3 with v1 in history", p) } // Running the same promotion again is a no-op success, not a failure. postGated(t, fx, c, fx.u("/apps/kass/promote"), url.Values{"csrf": {csrf}, "version": {"v3"}, "channel": {"stable"}, "confirmed": {"1"}}) if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "already serves v3") { t.Fatalf("a repeated promotion was not a no-op:\n%s", body) } // Unshipped version: refused before any gate, pointer unmoved. postForm(t, c, fx.u("/apps/kass/promote"), url.Values{"csrf": {csrf}, "version": {"v9"}, "channel": {"stable"}}) if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "not a shipped release") { t.Fatal("no bad-version flash") } if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v3" { t.Fatalf("stable moved to %s on a refused promote", p.Version) } // Invalid channel name refused. postForm(t, c, fx.u("/apps/kass/promote"), url.Values{"csrf": {csrf}, "version": {"v1"}, "new_channel": {"bad//name"}}) if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Invalid channel name.") { t.Fatal("no invalid-channel flash") } // CSRF enforced; non-owners see the same 404 as everyone else. if status, _, _ := postForm(t, c, fx.u("/apps/kass/promote"), url.Values{"csrf": {"wrong"}, "version": {"v1"}, "channel": {"stable"}}); status != http.StatusForbidden { t.Fatalf("bad csrf promote = %d, want 403", status) } fx.setUser("bob@keymail.dev") cb := newBrowser(t) signUp(t, fx, cb, "code-bob-p") if status, _, _ := postForm(t, cb, fx.u("/apps/kass/promote"), url.Values{"csrf": {sessionToken(t, fx, cb)}, "version": {"v2"}, "channel": {"stable"}}); status != http.StatusNotFound { t.Fatalf("non-owner promote = %d, want 404", status) } } func TestRollbackFlow(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) // Fixture stable is v1 with no history: no rollback button for it (nor // for the single-promote canary), and a forced POST politely refuses. _, _, body := get(t, c, fx.u("/apps/kass/releases")) if strings.Contains(body, `name="channel" value="stable"`) || strings.Contains(body, `name="channel" value="canary/x"`) { t.Fatal("rollback button rendered for a channel with no history") } postForm(t, c, fx.u("/apps/kass/rollback"), url.Values{"csrf": {csrf}, "channel": {"canary/x"}}) if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "no previous version") { t.Fatal("no empty-history rollback flash") } // Put v2 over v1 on stable, then roll back: the pointer returns to v1 and // v2 becomes the (re-)rollback target. Seeded through the release // primitive rather than promoted through the console, because this test is // about rollback and the ladder has its own (TestLadderRefusesASkip). seedChannel(t, fx, "kass", "stable", "v2") if _, _, body := get(t, c, fx.u("/apps/kass/releases")); !strings.Contains(body, `name="channel" value="stable"`) { t.Fatal("no rollback button after stable gained history") } // stable is gated (owner + step-up, mirroring promoteGate's legacy path // — Task 8/9 review), so this walks the passkey round trip like every // other stable-rung action. status, loc, _ := postGated(t, fx, c, fx.u("/apps/kass/rollback"), url.Values{"csrf": {csrf}, "channel": {"stable"}, "confirmed": {"1"}}) if status != http.StatusSeeOther || loc != fx.path("/apps/kass") { t.Fatalf("rollback = %d -> %q", status, loc) } p := fx.channelVersion(t, "kass", "stable") if p.Version != "v1" || len(p.History) == 0 || p.History[0] != "v2" { t.Fatalf("after rollback stable = %+v, want v1 with v2 first in history", p) } if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Rolled back stable to v1.") { t.Fatal("no rollback flash") } // Same happy path, but posted from the releases tab (back=releases): the // redirect must land back on the tab, not the app page. stable is v1 with // v2 in history after the rollback above, so a second rollback is valid. status, loc, _ = postGated(t, fx, c, fx.u("/apps/kass/rollback"), url.Values{"csrf": {csrf}, "channel": {"stable"}, "back": {"releases"}, "confirmed": {"1"}}) if status != http.StatusSeeOther || loc != fx.path("/apps/kass/releases") { t.Fatalf("rollback with back=releases = %d -> %q, want %q", status, loc, fx.path("/apps/kass/releases")) } } // TestLegacyStableRollbackGatedByOwnerAndStepUp pins the Task 8/9 review's // pre-existing-gap fix: a legacy (pipeline-less) app's stable rollback used // to carry NO gate at all on this door — while the API door 403s it outright // and a PROMOTION onto stable takes owner + passkey (promoteGate). It now // mirrors promoteGate's legacy-path shape exactly: byte-identical 404 for a // non-owner, requireFreshAuth last for an owner. A non-stable rung is // unchanged — membership alone, no owner or step-up gate. func TestLegacyStableRollbackGatedByOwnerAndStepUp(t *testing.T) { fx := newConsoleFixture(t) seedChannel(t, fx, "kass", "stable", "v2") // history to roll back to if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } // The owner, signed in FIRST — fx.setUser below points the fake OAuth // provider at sam for the member checks, and signInPaul relies on the // fixture's default (paul) at the moment it signs up. c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) // A member: the byte-identical 404, same as a promotion onto stable — // and never the confirm card first (IMPORTANT 1, Task 9 review): the // owner gate must fire BEFORE confirmRollback, not after a card that // already conceded the action would otherwise proceed. fx.setUser("sam@keymail.dev") cm := newBrowser(t) signUp(t, fx, cm, "code-sam-rollback-stable") csrfSam := sessionToken(t, fx, cm) if status, _, body := postForm(t, cm, fx.u("/apps/kass/rollback"), url.Values{"csrf": {csrfSam}, "channel": {"stable"}}); status != http.StatusNotFound { t.Fatalf("member stable rollback (unconfirmed) = %d, want 404", status) } else if body != "404 page not found\n" { t.Fatalf("member stable rollback body = %q, want the stdlib http.NotFound output — not a confirm card", body) } if status, _, _ := postForm(t, cm, fx.u("/apps/kass/rollback"), url.Values{"csrf": {csrfSam}, "channel": {"stable"}, "confirmed": {"1"}}); status != http.StatusNotFound { t.Fatalf("member stable rollback (confirmed) = %d, want 404", status) } if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v2" { t.Fatalf("stable moved to %s on a refused member rollback", p.Version) } // The button itself is hidden from a member on the releases page — not // merely refused on click. if _, _, mbody := get(t, cm, fx.u("/apps/kass/releases")); strings.Contains(mbody, `name="channel" value="stable"`) { t.Fatalf("member sees a Roll back control for stable, want it hidden:\n%s", mbody) } // The owner: a plain POST with no step-up does not move the pointer — it // is redirected into Keymail's re-authentication, same as a stable // promotion (TestLadderPromoteToStableNeedsStepUp). The fake OAuth // provider's identity is reset to paul first — the member check above // left it pointed at sam, and completing the step-up exchanges a code // against whatever address is currently configured there. fx.setUser("paul@keymail.dev") status, loc, _ := postForm(t, c, fx.u("/apps/kass/rollback"), url.Values{"csrf": {csrf}, "channel": {"stable"}, "confirmed": {"1"}}) if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { t.Fatalf("un-stepped-up owner stable rollback = %d -> %q, want a redirect into Keymail", status, loc) } if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v2" { t.Fatalf("stable moved to %s with no passkey confirmation", p.Version) } // Completing the step-up carries the rollback out. if status, _, body := completeStepUp(t, fx, c, loc); status != http.StatusSeeOther { t.Fatalf("resumed rollback = %d:\n%s", status, body) } if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v1" { t.Fatalf("stable = %s after a confirmed, stepped-up rollback, want v1", p.Version) } // A non-stable rung is unchanged: membership alone, no owner/step-up gate. shipVersion(t, fx, "kass", "v8") shipVersion(t, fx, "kass", "v9") seedChannel(t, fx, "kass", "beta", "v8") seedChannel(t, fx, "kass", "beta", "v9") fx.setUser("sam@keymail.dev") cm2 := newBrowser(t) signUp(t, fx, cm2, "code-sam-rollback-beta") status, loc, _ = postForm(t, cm2, fx.u("/apps/kass/rollback"), url.Values{"csrf": {sessionToken(t, fx, cm2)}, "channel": {"beta"}, "confirmed": {"1"}}) if status != http.StatusSeeOther || loc != fx.path("/apps/kass") { t.Fatalf("member beta rollback = %d -> %q, want a plain 303 into the app page (no gate)", status, loc) } if p := fx.channelVersion(t, "kass", "beta"); p.Version != "v8" { t.Fatalf("beta = %s after a member's ungated rollback, want v8", p.Version) } } // addRoute upserts a registry route into the fixture's registry. func (fx *consoleFixture) addRoute(t *testing.T, rt registry.Route) { t.Helper() reg, err := registry.Open(fx.regPath) if err != nil { t.Fatal(err) } if err := reg.Upsert(rt); err != nil { t.Fatal(err) } } func TestCanaryFlow(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) // The releases tab: create form, and the fixture canary listed hostless. _, _, body := get(t, c, fx.u("/apps/kass/releases")) for _, want := range []string{ `action="/a/bab/apps/kass/canary"`, `action="/a/bab/apps/kass/canary/retire"`, "canary/x", "no host yet", "Create canary", } { if !strings.Contains(body, want) { t.Fatalf("/apps/kass/releases missing %q in:\n%s", want, body) } } // Create a canary: a fresh canary/rc channel pointed at v1, no bake. status, loc, _ := postForm(t, c, fx.u("/apps/kass/canary"), url.Values{"csrf": {csrf}, "name": {"rc"}, "version": {"v1"}}) if status != http.StatusSeeOther || loc != fx.path("/apps/kass") { t.Fatalf("canary = %d -> %q", status, loc) } if p := fx.channelVersion(t, "kass", "canary/rc"); p.Version != "v1" { t.Fatalf("canary/rc pointer = %+v, want v1", p) } _, _, body = get(t, c, fx.u("/apps/kass")) // The flash names the API-backed verb. `carlos add` is box-local (it opens // the box's registry on local disk), so a dashboard must never send its // reader there — the whole point of the instances API path. for _, want := range []string{ "Created canary rc", "no safety delay", "canary/rc", "carlos instances enable --app kass", "carlos instances create --app kass", "--channel canary/rc", } { if !strings.Contains(body, want) { t.Fatalf("post-create page missing %q in:\n%s", want, body) } } if strings.Contains(body, "carlos add ") { t.Errorf("post-create page still sends the reader at box-local `carlos add`:\n%s", body) } // New means new: the existing canary refuses re-creation. postForm(t, c, fx.u("/apps/kass/canary"), url.Values{"csrf": {csrf}, "name": {"x"}, "version": {"v1"}}) if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Canary x already exists") { t.Fatal("no already-exists flash") } if p := fx.channelVersion(t, "kass", "canary/x"); p.Version != "v2" { t.Fatalf("canary/x moved to %s on a refused create", p.Version) } // Bad names, unshipped versions, CSRF. postForm(t, c, fx.u("/apps/kass/canary"), url.Values{"csrf": {csrf}, "name": {"Bad Name"}, "version": {"v1"}}) if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Invalid canary name") { t.Fatal("no invalid-name flash") } postForm(t, c, fx.u("/apps/kass/canary"), url.Values{"csrf": {csrf}, "name": {"ok-name"}, "version": {"v9"}}) if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "not a shipped release") { t.Fatal("no unshipped-version flash") } if status, _, _ := postForm(t, c, fx.u("/apps/kass/canary"), url.Values{"csrf": {"wrong"}, "name": {"rc2"}, "version": {"v1"}}); status != http.StatusForbidden { t.Fatalf("bad csrf canary = %d, want 403", status) } // Non-owners get the same 404 as everyone else. fx.setUser("bob@keymail.dev") cb := newBrowser(t) signUp(t, fx, cb, "code-bob-c") if status, _, _ := postForm(t, cb, fx.u("/apps/kass/canary"), url.Values{"csrf": {sessionToken(t, fx, cb)}, "name": {"evil"}, "version": {"v1"}}); status != http.StatusNotFound { t.Fatalf("non-owner canary = %d, want 404", status) } } func TestCanaryURLFromRoute(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) // A host already follows canary/rc (routes can pre-exist their channel); // creating the canary makes it live there, and the console says so. fx.addRoute(t, registry.Route{Host: "rc.kass.carlosapp.dev", Kind: "instance", Status: "ready", App: "kass", Channel: "canary/rc", Canary: true}) postForm(t, c, fx.u("/apps/kass/canary"), url.Values{"csrf": {csrf}, "name": {"rc"}, "version": {"v1"}}) _, _, body := get(t, c, fx.u("/apps/kass")) if !strings.Contains(body, "live at https://rc.kass.carlosapp.dev") { t.Fatalf("create flash has no canary URL:\n%s", body) } // The Canaries table itself is on the Releases tab now. _, _, rbody := get(t, c, fx.u("/apps/kass/releases")) if !strings.Contains(rbody, `rc.kass.carlosapp.dev`) { t.Fatalf("canary table has no host link:\n%s", rbody) } } func TestRetireCanary(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) // A canary with a follower refuses to go: retiring the pointer would // strand the host. fx.addRoute(t, registry.Route{Host: "x.kass.carlosapp.dev", Kind: "instance", Status: "ready", App: "kass", Channel: "canary/x", Canary: true}) postForm(t, c, fx.u("/apps/kass/canary/retire"), url.Values{"csrf": {csrf}, "name": {"x"}}) if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "still has a host") { t.Fatal("no still-followed flash") } if p := fx.channelVersion(t, "kass", "canary/x"); p.Version != "v2" { t.Fatal("followed canary was retired anyway") } // Unfollow, retire: the pointer is gone, the releases are not. reg, err := registry.Open(fx.regPath) if err != nil { t.Fatal(err) } if err := reg.Delete("x.kass.carlosapp.dev"); err != nil { t.Fatal(err) } status, loc, _ := postForm(t, c, fx.u("/apps/kass/canary/retire"), url.Values{"csrf": {csrf}, "name": {"x"}, "confirmed": {"1"}}) if status != http.StatusSeeOther || loc != fx.path("/apps/kass") { t.Fatalf("retire = %d -> %q", status, loc) } if _, _, err := fx.rel.Get(context.Background(), release.ChannelKey(release.OpsAccount, "kass", "canary/x")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("canary/x pointer still present: %v", err) } if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "kass", "v2"); err != nil { t.Fatalf("retire touched a shipped release: %v", err) } _, _, body := get(t, c, fx.u("/apps/kass")) if !strings.Contains(body, "Retired canary x") { t.Fatal("no retired flash") } if strings.Contains(body, "canary/x") { t.Fatal("retired canary still listed") } // Retiring what isn't there is a polite no. postForm(t, c, fx.u("/apps/kass/canary/retire"), url.Values{"csrf": {csrf}, "name": {"ghost"}}) if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "No canary named ghost") { t.Fatal("no no-such-canary flash") } if status, _, _ := postForm(t, c, fx.u("/apps/kass/canary/retire"), url.Values{"csrf": {"wrong"}, "name": {"x"}}); status != http.StatusForbidden { t.Fatalf("bad csrf retire = %d, want 403", status) } } // TestRetireCanaryFailsClosedOnRegistryError pins the fail-closed contract: a // registry that can't be read must never be read as "no hosts follow this // channel" — that would let a transient hiccup delete a channel pointer live // hosts still follow. Deliberately does NOT touch /apps/kass before the // retire call (any earlier request would populate the console's process-wide // registry handle cache and hide the failure) — the retire POST is the very // first console-side access to this fixture's registry file, and by then the // file on disk has been turned into a directory so the open fails outright. func TestRetireCanaryFailsClosedOnRegistryError(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) if err := os.Remove(fx.regPath); err != nil { t.Fatal(err) } if err := os.Mkdir(fx.regPath, 0o755); err != nil { t.Fatal(err) } status, _, body := postForm(t, c, fx.u("/apps/kass/canary/retire"), url.Values{"csrf": {csrf}, "name": {"x"}}) if status != http.StatusInternalServerError { t.Fatalf("retire with an unreadable registry = %d, want 500 (fail closed)", status) } if !strings.Contains(body, "Could not verify no hosts follow this channel") { t.Fatalf("retire-with-broken-registry body = %q, want the fail-closed refusal", body) } } func TestAppPageInstances(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) // Before the app reports its own internal instances: no "Reported by the // app itself" disclosure — the tab is exactly the route-level view (which // always renders its own "Running" header, fixture routes and all). if _, _, body := get(t, c, fx.u("/apps/kass/instances")); strings.Contains(body, "Reported by the app itself") { t.Fatal("\"Reported by the app itself\" section rendered with nothing reporting") } // Kass starts reporting its three training instances. app := newInstanceApp(t, kassReport) fx.addRoute(t, registry.Route{Host: app.host(t), Kind: "instance", Status: "ready", App: "kass"}) _, _, body := get(t, c, fx.u("/apps/kass/instances")) for _, want := range []string{ "

Instances

", "Reported by the app itself", "tracks its own internal workers", "riders-2026", "spring-camp", "trial-run", "eu-west-1", "archived", "read-only since June", } { if !strings.Contains(body, want) { t.Fatalf("/apps/kass/instances missing %q in:\n%s", want, body) } } } // TestRecentActivity pins the activity tab against real, console-driven // events (not synthetic RecordEvent calls) — TestActivityTab only checks the // tab's chrome, so this is where seeded rows actually render. It also pins // activityCap=50: past the cap, the oldest rows fall off and the newest 50 // (and no more) are what's on the page. func TestRecentActivity(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) // Fresh activity tab: no activity yet. if _, _, body := get(t, c, fx.u("/apps/kass/activity")); !strings.Contains(body, "Nothing yet") { t.Fatal("activity tab did not show its empty state before any action") } // A promotion the ladder allows (a fresh build entering at edge), and a // rollback of a channel seeded with something to roll back to. shipVersion(t, fx, "kass", "v3") seedChannel(t, fx, "kass", "stable", "v2") postForm(t, c, fx.u("/apps/kass/promote"), url.Values{"csrf": {csrf}, "version": {"v3"}, "new_channel": {"edge"}, "confirmed": {"1"}}) // stable is gated (owner + step-up, mirroring promoteGate's legacy path // — Task 8/9 review), so the confirmed post walks the passkey round trip. postGated(t, fx, c, fx.u("/apps/kass/rollback"), url.Values{"csrf": {csrf}, "channel": {"stable"}, "confirmed": {"1"}}) postForm(t, c, fx.u("/apps/kass/canary"), url.Values{"csrf": {csrf}, "name": {"rc"}, "version": {"v1"}}) postForm(t, c, fx.u("/apps/kass/canary/retire"), url.Values{"csrf": {csrf}, "name": {"rc"}, "confirmed": {"1"}}) _, _, body := get(t, c, fx.u("/apps/kass/activity")) for _, want := range []string{ "paul@keymail.dev", "promoted v3 to edge", "rolled back stable to v1", "created canary rc at v1", "retired canary rc", } { if !strings.Contains(body, want) { t.Fatalf("activity missing %q in:\n%s", want, body) } } // Newest first: the retire outranks the promote. if strings.Index(body, "retired canary rc") > strings.Index(body, "promoted v3 to edge") { t.Fatal("activity is not newest-first") } // Push the account's own event count well past the cap. The 4 real // actions above are now the oldest rows in the account, so this also // exercises them falling off — not just synthetic filler. for i := 0; i < 60; i++ { if err := fx.st.RecordEvent(OpsAccountID, "kass", "paul@keymail.dev", "marker", fmt.Sprintf("cap-marker-%03d", i), time.Now()); err != nil { t.Fatal(err) } } _, _, capped := get(t, c, fx.u("/apps/kass/activity")) if n := strings.Count(capped, "cap-marker-"); n != 50 { t.Fatalf("activity tab rendered %d rows past the cap, want exactly 50 (activityCap)", n) } if !strings.Contains(capped, "cap-marker-059") { t.Fatal("newest event fell off the capped activity tab") } if strings.Contains(capped, "cap-marker-000") || strings.Contains(capped, "promoted v3 to edge") { t.Fatal("activity tab kept rows older than its 50-row cap should allow") } } func TestActivityTab(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) status, _, body := get(t, c, fx.u("/apps/kass/activity")) if status != 200 { t.Fatalf("GET activity tab = %d", status) } for _, want := range []string{`aria-current="page">Activity`} { if !strings.Contains(body, want) { t.Fatalf("activity tab missing %q", want) } } assertNonMember404(t, fx, "/apps/kass/activity") } // NOTE: Assemble groups routes by app NAME with no account filter (view.go), // so two accounts that happen to own an app of the same name share routes // here — rival-account hosts render in this tab's Domains table, and the // same leak reaches the overview tab's ladder. PRE-EXISTING, gated (the app // page's session → membership → HasAppIn 404 runs before Assemble, so this // is not a non-member leak), and neither introduced nor fixed by this task — // fixing it there would also decide what the half-migrated AccountMismatch // banner shows, a separate call. Source fix: view.go:294-308. func TestAppSettingsTab(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) status, _, body := get(t, c, fx.u("/apps/kass/settings")) if status != 200 { t.Fatalf("GET app settings = %d", status) } for _, want := range []string{ `aria-current="page">Settings`, "

Identity

", } { if !strings.Contains(body, want) { t.Fatalf("app settings missing %q", want) } } _, _, domains := get(t, c, fx.u("/apps/kass/settings/domains")) if !strings.Contains(domains, "Domains & TLS") { t.Fatal("app Domains settings section is missing its heading") } assertNonMember404(t, fx, "/apps/kass/settings") } func TestPromotionsDisabledWithoutKey(t *testing.T) { fx := newConsoleFixtureKeyed(t, false) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) _, _, body := get(t, c, fx.u("/apps/kass")) if !strings.Contains(body, "Deploys are unavailable on this console — it has no signing key.") { t.Fatal("keyless console is missing the disabled note") } // The Domains section is a read — keyless (read-only) mode still renders it. if _, _, sbody := get(t, c, fx.u("/apps/kass/settings/domains")); !strings.Contains(sbody, "Domains") || !strings.Contains(sbody, "kass.carlosapp.dev") { t.Fatal("keyless console is missing the Domains section") } _, _, rbody := get(t, c, fx.u("/apps/kass/releases")) if strings.Contains(rbody, `action="/a/bab/apps/kass/promote"`) || strings.Contains(rbody, `action="/a/bab/apps/kass/rollback"`) || strings.Contains(rbody, `action="/a/bab/apps/kass/canary"`) || strings.Contains(rbody, `action="/a/bab/apps/kass/canary/retire"`) { t.Fatal("keyless console rendered mutation forms") } if status, _, _ := postForm(t, c, fx.u("/apps/kass/promote"), url.Values{"csrf": {csrf}, "version": {"v2"}, "channel": {"stable"}}); status != http.StatusServiceUnavailable { t.Fatalf("keyless promote = %d, want 503", status) } if status, _, _ := postForm(t, c, fx.u("/apps/kass/rollback"), url.Values{"csrf": {csrf}, "channel": {"stable"}}); status != http.StatusServiceUnavailable { t.Fatalf("keyless rollback = %d, want 503", status) } if status, _, _ := postForm(t, c, fx.u("/apps/kass/canary"), url.Values{"csrf": {csrf}, "name": {"rc"}, "version": {"v1"}}); status != http.StatusServiceUnavailable { t.Fatalf("keyless canary create = %d, want 503", status) } if status, _, _ := postForm(t, c, fx.u("/apps/kass/canary/retire"), url.Values{"csrf": {csrf}, "name": {"x"}}); status != http.StatusServiceUnavailable { t.Fatalf("keyless canary retire = %d, want 503", status) } // The bake controls sign too — the flag is a root-signed object and // fast-track re-signs a pointer, so both are mutations a keyless console // must refuse rather than fail halfway through. if status, _, _ := postForm(t, c, fx.u("/apps/kass/production"), url.Values{"csrf": {csrf}, "production": {"on"}}); status != http.StatusServiceUnavailable { t.Fatalf("keyless production flag = %d, want 503", status) } if status, _, _ := postForm(t, c, fx.u("/apps/kass/fasttrack"), url.Values{"csrf": {csrf}, "channel": {"stable"}}); status != http.StatusServiceUnavailable { t.Fatalf("keyless fast-track = %d, want 503", status) } if f, err := release.GetProduction(context.Background(), fx.rel, fx.pub, release.OpsAccount, "kass"); err != nil || f != nil { t.Fatalf("keyless console wrote a production flag: %+v (%v)", f, err) } if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v1" { t.Fatalf("keyless console moved a pointer to %s", p.Version) } if p := fx.channelVersion(t, "kass", "canary/x"); p.Version != "v2" { t.Fatalf("keyless console touched canary/x (%s)", p.Version) } } // ---- domains ---------------------------------------------------------------- // TestClaimNeedsNoLinkageManifest: the app→account linkage IS the bucket key // path now, so claiming an app writes NO apps//account.json (that // manifest is retired) — and the account's own manifest is what the agent // materializes from. func TestClaimNeedsNoLinkageManifest(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) if status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"zephyr"}}); status != http.StatusSeeOther || loc != fx.path("/apps/zephyr") { t.Fatalf("claim = %d -> %q", status, loc) } if _, _, err := fx.rel.Get(context.Background(), "apps/zephyr/account.json"); !errors.Is(err, os.ErrNotExist) { t.Errorf("claim wrote a retired linkage manifest: %v", err) } if _, _, err := fx.rel.Get(context.Background(), AccountManifestKey(fx.sqid)); err != nil { t.Errorf("no account manifest in the bucket: %v", err) } } // TestAppPageBackfillsAccountManifest: an account whose manifest never // reached the bucket (minted while it was unreachable, or the built-in ops // account, which predates the feature) gets one written on a page view — and // an existing manifest is never overwritten, because rewriting a different id // there would re-home every platform domain the account owns. func TestAppPageBackfillsAccountManifest(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) key := AccountManifestKey(fx.sqid) if _, _, err := fx.rel.Get(context.Background(), key); !errors.Is(err, os.ErrNotExist) { t.Fatalf("fixture already has a manifest: %v", err) } if status, _, _ := get(t, c, fx.u("/apps/kass")); status != 200 { t.Fatalf("app page = %d", status) } body, _, err := fx.rel.Get(context.Background(), key) if err != nil { t.Fatalf("view did not backfill the manifest: %v", err) } var m struct { ID int64 `json:"id"` Sqid string `json:"sqid"` } if err := json.Unmarshal(body, &m); err != nil { t.Fatalf("manifest not JSON: %v (%q)", err, body) } if m.ID != OpsAccountID || m.Sqid != fx.sqid { t.Errorf("backfilled manifest = %+v, want the ops account", m) } // A pre-existing manifest is identity every box has already materialized. custom := []byte(`{"id":1,"sqid":"bab","name":"renamed by hand"}`) if _, err := fx.rel.Put(context.Background(), key, custom, m.Sqid); err == nil { t.Fatal("expected a CAS-guarded overwrite to need the current etag") } _, etag, _ := fx.rel.Get(context.Background(), key) if _, err := fx.rel.Put(context.Background(), key, custom, etag); err != nil { t.Fatal(err) } if status, _, _ := get(t, c, fx.u("/apps/kass")); status != 200 { t.Fatalf("app page = %d", status) } body, _, err = fx.rel.Get(context.Background(), key) if err != nil || string(body) != string(custom) { t.Errorf("view clobbered an existing manifest: %q %v", body, err) } } // TestDomainViewStates is the pure Domains-row builder over route fixtures: // type from the platform flag, unknown DNS as "" (rendered em-dash), // guidance ONLY for non-pointing custom domains — except "unresolved" (our // lookup failed; guidance would blame the member for our outage) — canary // labeling preserved. func TestDomainViewStates(t *testing.T) { edge := DNSTargets{EdgeIPs: []string{"203.0.113.7", "198.51.100.9"}} renew := time.Date(2026, 10, 12, 3, 0, 0, 0, time.UTC).Unix() cases := []struct { name string rt registry.Route dns DNSTargets want DomainView }{ {"platform pointing ready with cert", registry.Route{Host: "kass.bab.oncarlos.com", Platform: true, Status: "ready", DNSStatus: "pointing", CertExpiresAt: renew}, edge, DomainView{Domain: "kass.bab.oncarlos.com", Type: "platform", DNS: "pointing", TLS: "ready · renews 2026-10-12"}}, {"custom elsewhere gets guidance", registry.Route{Host: "kass.training", Status: "ready", DNSStatus: "elsewhere"}, edge, DomainView{Domain: "kass.training", Type: "custom", DNS: "elsewhere", TLS: "ready", Guidance: "Point an A record for kass.training at 203.0.113.7, 198.51.100.9. Checks run every few minutes."}}, {"custom unknown: em-dash DNS, still guided", registry.Route{Host: "kass.training", Status: "issuing"}, edge, DomainView{Domain: "kass.training", Type: "custom", DNS: "", TLS: "issuing", Guidance: "Point an A record for kass.training at 203.0.113.7, 198.51.100.9. Checks run every few minutes."}}, {"custom pointing: no guidance", registry.Route{Host: "kass.training", Status: "ready", DNSStatus: "pointing"}, edge, DomainView{Domain: "kass.training", Type: "custom", DNS: "pointing", TLS: "ready"}}, {"custom unresolved: our lookup failed, no guidance — not the member's problem", registry.Route{Host: "kass.training", Status: "ready", DNSStatus: "unresolved"}, edge, DomainView{Domain: "kass.training", Type: "custom", DNS: "unresolved", TLS: "ready"}}, {"platform elsewhere: never guided (we made this domain; guidance is for THEIR dns)", registry.Route{Host: "kass.bab.oncarlos.com", Platform: true, Status: "ready", DNSStatus: "elsewhere"}, edge, DomainView{Domain: "kass.bab.oncarlos.com", Type: "platform", DNS: "elsewhere", TLS: "ready"}}, {"canary row keeps its label", registry.Route{Host: "rc.kass.bab.oncarlos.com", Platform: true, Status: "ready", DNSStatus: "pointing", Channel: "canary/rc", Canary: true}, edge, DomainView{Domain: "rc.kass.bab.oncarlos.com", Type: "platform · canary/rc", DNS: "pointing", TLS: "ready"}}, {"no known edge IPs: guidance still helps, without inventing an address", registry.Route{Host: "kass.training", Status: "ready", DNSStatus: "elsewhere"}, DNSTargets{}, DomainView{Domain: "kass.training", Type: "custom", DNS: "elsewhere", TLS: "ready", Guidance: "Point an A record for kass.training at this deployment's edge IP. Checks run every few minutes."}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := domainView(c.rt, c.dns); got != c.want { t.Errorf("domainView:\n got %+v\n want %+v", got, c.want) } }) } } // TestDomainGuidanceSteering pins the custom-domain attach guidance across // the steering axis (multi-region ingress spec, Part 4): // // - no steering hostname (every deployment today): byte-for-byte the // sentence this console has always shown — the whole inertness claim; // - steering armed + a subdomain: the CNAME to edge. is the // recommended form, with the A record still offered; // - steering armed + an APEX: no CNAME is offered at all (it is illegal // there) and the round-robin-without-latency-bias truth is stated — // the spec's honesty rule. Apex-ness comes from the public suffix list, // so a three-label apex (example.co.uk) is an apex and a three-label // subdomain (app.example.com) is not. func TestDomainGuidanceSteering(t *testing.T) { ips := []string{"203.0.113.7", "198.51.100.9"} steered := DNSTargets{EdgeIPs: ips, SteeringHost: "edge.oncarlos.com"} cases := []struct { name, host string dns DNSTargets want string }{ {"unsteered deployment is untouched", "kass.training", DNSTargets{EdgeIPs: ips}, "Point an A record for kass.training at 203.0.113.7, 198.51.100.9. Checks run every few minutes."}, {"unsteered, no edge IPs", "kass.training", DNSTargets{}, "Point an A record for kass.training at this deployment's edge IP. Checks run every few minutes."}, {"steered subdomain is offered the CNAME", "www.kass.training", steered, "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."}, {"steered apex keeps A records, honestly", "kass.training", steered, "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."}, {"multi-label public suffix is still an apex", "kass.co.uk", steered, "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."}, {"three labels under a plain TLD is a subdomain", "app.kass.co.uk", steered, "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."}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := domainGuidance(c.host, c.dns); got != c.want { t.Errorf("domainGuidance(%q):\n got %q\n want %q", c.host, got, c.want) } }) } } // TestSteeringHostNormalization: the console never pastes an unusable // steering hostname into member-facing guidance — a malformed value degrades // to the A-record form rather than into the sentence. func TestSteeringHostNormalization(t *testing.T) { for raw, want := range map[string]string{ "": "", " Edge.OnCarlos.com ": "edge.oncarlos.com", "edge.oncarlos.com.": "edge.oncarlos.com", "*.oncarlos.com": "", "not a host": "", } { if got := normalizeSteeringHost(raw); got != want { t.Errorf("normalizeSteeringHost(%q) = %q, want %q", raw, got, want) } } } // TestAppPageRendersDomains: the app page's Domains table renders from // registry state — platform + custom rows, em-dash unknowns, and the // guidance row beneath a non-pointing custom domain. func TestAppPageRendersDomains(t *testing.T) { fx := newConsoleFixture(t) fx.addRoute(t, registry.Route{Host: "kass.bab.carlosapp.dev", Kind: "instance", Status: "ready", App: "kass", Platform: true, DNSStatus: "pointing", Account: "paul@keymail.dev"}) c := signInPaul(t, fx) _, _, body := get(t, c, fx.u("/apps/kass/settings/domains")) for _, want := range []string{ "Domains", "kass.bab.carlosapp.dev", "platform", "pointing", "kass.carlosapp.dev", "custom", "—", "Point an A record for kass.carlosapp.dev at this deployment's edge IP.", "Checks run every few minutes.", } { if !strings.Contains(body, want) { t.Fatalf("/apps/kass/settings missing %q in:\n%s", want, body) } } } // seedFleetJSON writes a small status/fleet.json fixture through the real // production path (one box snapshot, then BuildFleet + PublishFleet reading // the fixture registry's routes) rather than hand-rolling JSON — so the test // exercises the same shape /fleet will see in production. func seedFleetJSON(t *testing.T, fx *consoleFixture) *status.Fleet { t.Helper() ctx := context.Background() if err := status.WriteBox(ctx, fx.rel, status.Box{ GeneratedAt: time.Now(), Label: "ada-stargazing", Region: "eu-west-1", Routes: status.RouteCounts{Static: 2, Resident: 1}, }); err != nil { t.Fatal(err) } reg, err := registry.Open(fx.regPath) if err != nil { t.Fatal(err) } routes, err := reg.List() if err != nil { t.Fatal(err) } f, err := status.BuildFleet(ctx, fx.rel, routes, time.Now(), 5*time.Minute) if err != nil { t.Fatal(err) } if err := status.PublishFleet(ctx, fx.rel, f); err != nil { t.Fatal(err) } return f } // TestFleetOperatorOnly is Task 9's core gate: signed out, signed in but not // an operator, and a revoked operator all get exactly the same 404 an // actually-missing route gets — role existence must not be probeable. // Granting flips the same session straight into the rendered fleet view. func TestFleetOperatorOnly(t *testing.T) { fx := newConsoleFixture(t) seedFleetJSON(t, fx) out := newBrowser(t) statusGhost, _, bodyGhost := get(t, out, fx.ts.URL+"/this-route-does-not-exist") if statusGhost != http.StatusNotFound { t.Fatalf("sanity: a missing route = %d, want 404", statusGhost) } if status, _, body := get(t, out, fx.ts.URL+"/fleet"); status != http.StatusNotFound || body != bodyGhost { t.Fatalf("signed-out /fleet = %d %q, want the same 404 as a missing route (%d %q)", status, body, statusGhost, bodyGhost) } // Signed in and owns kass, but is not an operator: still the same 404. owner := signInPaul(t, fx) if status, _, body := get(t, owner, fx.ts.URL+"/fleet"); status != http.StatusNotFound || body != bodyGhost { t.Fatalf("non-operator /fleet = %d %q, want the same 404 as a missing route", status, body) } if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { t.Fatal(err) } status, _, body := get(t, owner, fx.ts.URL+"/fleet") if status != 200 || !strings.Contains(body, "Fleet status") { t.Errorf("operator fleet view: %d %s", status, body) } for _, want := range []string{"ada-stargazing", "eu-west-1", "Boxes", "Apps", "Services"} { if !strings.Contains(body, want) { t.Fatalf("/fleet missing %q in:\n%s", want, body) } } // Revoked: back to the exact same 404. if err := fx.st.SetOperator("paul@keymail.dev", false); err != nil { t.Fatal(err) } if status, _, body := get(t, owner, fx.ts.URL+"/fleet"); status != http.StatusNotFound || body != bodyGhost { t.Fatalf("revoked operator /fleet = %d %q, want the same 404", status, body) } } // TestFleetEmptyState: a deployment where the status-lease holder has never // published status/fleet.json must render the page's empty state, never a // 500 — the object simply not existing yet is not an error. func TestFleetEmptyState(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { t.Fatal(err) } status, _, body := get(t, c, fx.ts.URL+"/fleet") if status != 200 || !strings.Contains(body, "Fleet status") || !strings.Contains(body, "No snapshots yet") { t.Fatalf("empty fleet state: %d %s", status, body) } } // TestFleetKeylessReadable: /fleet is a read, so a deployment with no // CARLOS_RELEASE_KEY configured (promotions disabled, console read-only) // must still serve it to an operator. func TestFleetKeylessReadable(t *testing.T) { fx := newConsoleFixtureKeyed(t, false) seedFleetJSON(t, fx) c := signInPaul(t, fx) if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { t.Fatal(err) } status, _, body := get(t, c, fx.ts.URL+"/fleet") if status != 200 || !strings.Contains(body, "Fleet status") { t.Fatalf("keyless /fleet = %d %s, want 200 with Fleet status", status, body) } } // seedHostBoxes writes one fresh and one long-dark box snapshot, both // carrying a private Metrics block, and publishes the merged feed over them — // the exact pair /fleet's Hosts detail has to tell apart. func seedHostBoxes(t *testing.T, fx *consoleFixture) { t.Helper() ctx := context.Background() now := time.Now() for _, b := range []status.Box{ { GeneratedAt: now, Label: "ada-stargazing", Region: "eu-west-1", Metrics: &status.BoxMetrics{ MemTotalBytes: pi64(2 << 30), MemAvailBytes: pi64(1 << 30), Load1: p(0.42), DiskFreeBytes: pi64(5 << 30), Release: "abc1234", Tunnel: "connected", }, }, { GeneratedAt: now.Add(-time.Hour), Label: "grace-kayaking", Region: "eu-west-2", Metrics: &status.BoxMetrics{ MemTotalBytes: pi64(9 << 30), MemAvailBytes: pi64(7 << 30), Load1: p(8.25), DiskFreeBytes: pi64(3 << 30), Release: "beefcaf", Tunnel: "connected", }, }, } { if err := status.WriteBox(ctx, fx.rel, b); err != nil { t.Fatal(err) } } f, err := status.BuildFleet(ctx, fx.rel, nil, now, status.SnapshotPeriod) if err != nil { t.Fatal(err) } if err := status.PublishFleet(ctx, fx.rel, f); err != nil { t.Fatal(err) } } // TestFleetHostsDetailSuppressesStaleMetrics is Task 7's core rule, on the one // surface allowed to render raw numbers at all: the operator fleet page shows // a live host's release, tunnel state, memory, load and free disk — and shows // an unreachable host as unreachable, with how long it has been dark and NOT // ONE of its last happy numbers. A dead box's final reading rendered as a // current figure is the failure this whole task exists to prevent. func TestFleetHostsDetailSuppressesStaleMetrics(t *testing.T) { fx := newConsoleFixture(t) seedHostBoxes(t, fx) c := signInPaul(t, fx) if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { t.Fatal(err) } code, _, body := get(t, c, fx.ts.URL+"/fleet") if code != 200 { t.Fatalf("/fleet = %d: %s", code, body) } for _, want := range []string{ "Hosts", "ada-stargazing", "abc1234", "connected", "1.0 GB", "2.0 GB", "0.42", "5.0 GB", // the fresh box's raw readings "grace-kayaking", "unreachable", } { if !strings.Contains(body, want) { t.Fatalf("/fleet hosts detail missing %q in:\n%s", want, body) } } for _, leaked := range []string{"beefcaf", "8.25", "7.0 GB", "9.0 GB", "3.0 GB"} { if strings.Contains(body, leaked) { t.Fatalf("unreachable host's stale reading %q rendered — a dark box must show no numbers:\n%s", leaked, body) } } } // TestFleetHostsDegradeWhenUnreadable: the Hosts detail is an extra read on a // page that already had a job. If the boxes can't be listed, the page still // renders the merged feed it could read — metrics are never the reason /fleet // 500s. func TestFleetHostsDegradeWhenUnreadable(t *testing.T) { fx := newConsoleFixture(t) seedFleetJSON(t, fx) fx.srv.cfg.Releases = listFailStore{fx.rel} c := signInPaul(t, fx) if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { t.Fatal(err) } code, _, body := get(t, c, fx.ts.URL+"/fleet") if code != 200 || !strings.Contains(body, "Fleet status") || !strings.Contains(body, "ada-stargazing") { t.Fatalf("/fleet with an unlistable bucket = %d, want the fleet feed still rendered:\n%s", code, body) } } // TestFleetHostsEscapesHostSuppliedStrings: Release and Tunnel are strings a // BOX writes into its own snapshot — the console reads them, it does not // mint them — so a compromised or buggy host must not be able to put markup // on an operator's page. html/template escapes them because they are plain // strings all the way through (never template.HTML, unlike icons.go's // vendored SVG); this test is what keeps that true. func TestFleetHostsEscapesHostSuppliedStrings(t *testing.T) { fx := newConsoleFixture(t) if err := status.WriteBox(context.Background(), fx.rel, status.Box{ GeneratedAt: time.Now(), Label: "leo-juggling", Region: "eu-west-1", Metrics: &status.BoxMetrics{Release: "", Tunnel: "connected"}, }); err != nil { t.Fatal(err) } c := signInPaul(t, fx) if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { t.Fatal(err) } code, _, body := get(t, c, fx.ts.URL+"/fleet") if code != 200 { t.Fatalf("/fleet = %d: %s", code, body) } if strings.Contains(body, "") { t.Fatalf("a host-supplied release string reached the page unescaped:\n%s", body) } if !strings.Contains(body, "<script>alert(1)</script>") { t.Fatalf("escaped release string not rendered at all:\n%s", body) } } // listFailStore is a store whose List always fails — everything else passes // through, so fleet.json still reads and only the boxes sweep breaks. type listFailStore struct{ release.Store } func (listFailStore) List(ctx context.Context, prefix string) ([]string, error) { return nil, errors.New("bucket list refused") } // TestFleetMetricsOperatorOnly is Task 11's gate, mirroring TestFleetOperatorOnly // for /fleet/metrics: signed out and signed in-but-not-operator both get // exactly the same 404 an unrouted URL gets; an operator gets 200 with a // seeded box label and unit name in the body. func TestFleetMetricsOperatorOnly(t *testing.T) { fx := newConsoleFixture(t) ctx := context.Background() at := time.Now().UTC().Add(-5 * time.Minute) chunk := metrics.RawChunk{ V: 1, Box: "box-metrics-a", Hour: metrics.HourOf(at), Samples: []metrics.Sample{ { At: at, Host: metrics.HostSample{CPUFrac: p(0.42), MemFrac: p(0.33), DiskFrac: p(0.5)}, Units: map[string]metrics.UnitSample{ "carlos-app@widget.service": {CPUFrac: p(0.11), MemBytes: pi64(1024)}, }, }, }, } if err := metrics.WriteRaw(ctx, fx.rel, chunk); err != nil { t.Fatal(err) } out := newBrowser(t) statusGhost, _, bodyGhost := get(t, out, fx.ts.URL+"/definitely-not-a-route") if statusGhost != http.StatusNotFound { t.Fatalf("sanity: a missing route = %d, want 404", statusGhost) } if status, _, body := get(t, out, fx.ts.URL+"/fleet/metrics"); status != http.StatusNotFound || body != bodyGhost { t.Fatalf("signed-out /fleet/metrics = %d %q, want the same 404 as a missing route (%d %q)", status, body, statusGhost, bodyGhost) } // Signed in and owns kass, but is not an operator: still the same 404. owner := signInPaul(t, fx) if status, _, body := get(t, owner, fx.ts.URL+"/fleet/metrics"); status != http.StatusNotFound || body != bodyGhost { t.Fatalf("non-operator /fleet/metrics = %d %q, want the same 404 as a missing route", status, body) } if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { t.Fatal(err) } status, _, body := get(t, owner, fx.ts.URL+"/fleet/metrics") if status != 200 { t.Fatalf("operator /fleet/metrics = %d, want 200: %s", status, body) } for _, want := range []string{"box-metrics-a", "carlos-app@widget.service"} { if !strings.Contains(body, want) { t.Fatalf("/fleet/metrics missing %q in:\n%s", want, body) } } } // TestFleetMetricsEmptyState: no box has ever published metrics — the normal // state right after a deployment — renders the page's empty state, never a // 500. func TestFleetMetricsEmptyState(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { t.Fatal(err) } status, _, body := get(t, c, fx.ts.URL+"/fleet/metrics") if status != 200 || !strings.Contains(body, "Fleet metrics") { t.Fatalf("empty /fleet/metrics: %d %s", status, body) } } // TestAppPageOwnerMetricsBoundary is Task 12's boundary, end to end: one box // carries this owner's unit, ANOTHER tenant's unit, and host-level readings. // The owner's app page shows their own instance's numbers and nothing else — // no other tenant's unit, no host totals, no box name. A route with no unit // data in the sweep (hibernating) renders as asleep, never as zeros. func TestAppPageOwnerMetricsBoundary(t *testing.T) { fx := newConsoleFixture(t) fx.addRoute(t, registry.Route{Host: "owned.example.com", Kind: "instance", Status: "ready", App: "kass", Backing: registry.BackingUnit}) fx.addRoute(t, registry.Route{Host: "asleep.example.com", Kind: "instance", Status: "ready", App: "kass", Backing: registry.BackingUnit, Hibernate: true}) // The manually-added shape: awake and serving, but from a process no // carlos-app@ cgroup covers and no stamped unit names either (bespoke // unit / exec child), so the sweep has nothing for it AND nowhere to // look. Non-hibernating, so "asleep" would be a lie; unprovisioned and // unstamped, so "Not responding" would be too — expectsReadings can't // tell, so the row must read "No readings" (2026-08-10 tito false alarm). fx.addRoute(t, registry.Route{Host: "unmetered.example.com", Kind: "instance", Status: "ready", App: "kass"}) ctx := context.Background() at := time.Now().UTC().Add(-time.Minute) // inside metricsFreshWindow if err := metrics.WriteRaw(ctx, fx.rel, metrics.RawChunk{ V: 1, Box: "box-secret-a", Hour: metrics.HourOf(at), Samples: []metrics.Sample{{ At: at, // Host sentinels chosen so their rendered forms ("78%", "89%", // "44%") cannot collide with any unit number on this page — and // deliberately NOT 0.999, whose "100%" collides with the // layout's own `width: 100%` CSS. Host: metrics.HostSample{CPUFrac: p(0.777), MemFrac: p(0.888), DiskFrac: p(0.444)}, Units: map[string]metrics.UnitSample{ "carlos-app@owned.example.com.service": {CPUFrac: p(0.11), MemBytes: pi64(1024)}, "carlos-app@other-tenant.example.com.service": {CPUFrac: p(0.55), MemBytes: pi64(4096)}, }, }}, }); err != nil { t.Fatal(err) } c := signInPaul(t, fx) status, _, body := get(t, c, fx.u("/apps/kass/instances")) if status != http.StatusOK { t.Fatalf("/apps/kass/instances = %d, want 200: %s", status, body) } // The per-host CPU/Mem readings now live as columns on the Instances // table itself (no separate Resources section) — "11%"/"1.0 KB" are // unique to owned.example.com's own row, so checking presence page-wide // still pins them to that route. Asleep vs Not responding vs No readings // is the Health column's job, not the metrics cell's, and the fixture // always carries other instance-kind rows (its own bystanders), so those // words are scoped to their own row below rather than checked page-wide // — a page-wide Contains would pass even if the wrong row said it. for _, want := range []string{ "owned.example.com", "11%", "1.0 KB", "asleep.example.com", "unmetered.example.com", } { if !strings.Contains(body, want) { t.Fatalf("app page missing %q in:\n%s", want, body) } } if row := instanceRow(t, body, "asleep.example.com"); !strings.Contains(row, "Asleep") { t.Fatalf("asleep.example.com's own row did not read Asleep:\n%s", row) } if row := instanceRow(t, body, "unmetered.example.com"); !strings.Contains(row, "No readings") || !strings.Contains(row, "no data") { t.Fatalf("unmetered.example.com's own row did not read No readings / no data:\n%s", row) } // The boundary: nothing about the other tenant, the host, or the box. for _, forbidden := range []string{ "other-tenant", "box-secret-a", // other tenant's unit, the box's name "55%", "4.0 KB", // the other tenant's readings "77.7", "78%", "89%", "44%", // host CPU/mem/disk totals } { if strings.Contains(body, forbidden) { t.Fatalf("app page leaked %q in:\n%s", forbidden, body) } } } // TestAppPageSurvivesMetricsFailure: metrics are decoration. A bucket that // fails every read must still leave the owner a fully rendered instances tab // — the only tab that reads metrics now that the overview doesn't. func TestAppPageSurvivesMetricsFailure(t *testing.T) { fx := newConsoleFixture(t) fx.addRoute(t, registry.Route{Host: "owned.example.com", Kind: "instance", Status: "ready", App: "kass", Backing: registry.BackingUnit}) fx.srv.cfg.Releases = failingListStore{fx.rel} c := signInPaul(t, fx) status, _, body := get(t, c, fx.u("/apps/kass/instances")) if status != http.StatusOK { t.Fatalf("/apps/kass/instances with a broken metrics read = %d, want 200: %s", status, body) } if !strings.Contains(body, `aria-current="page">Running`) { t.Fatalf("instances tab did not render its normal chrome: %s", body) } // Content, not just chrome: CPU/Mem readings now live as columns on the // route's own row, so a failed metrics read fails closed there — the // owned route still renders, with "no data" in place of numbers, not a // partially-rendered page. if !strings.Contains(body, "owned.example.com") || !strings.Contains(body, "no data") { t.Fatalf("instances tab did not render its route row with the metrics-failed state: %s", body) } } // failingListStore fails exactly the List the metrics sweep starts with, // leaving every other read (channels, manifests) working — the shape of a // bucket permission gap on metrics/ alone. type failingListStore struct{ release.Store } func (s failingListStore) List(ctx context.Context, prefix string) ([]string, error) { if strings.HasPrefix(prefix, "metrics/") { return nil, errors.New("boom") } return s.Store.List(ctx, prefix) } // TestAppPageOwnerMetricsCrossAccountAppName is the app-NAME collision: app // names are unique per account, not globally (store.AppAccounts), so two // accounts can each own an app called "kass". Assemble groups routes by app // name alone, so account B's routes arrive in account A's AppView — and the // metrics allowlist must not follow them. A's page must carry none of B's // instance readings. func TestAppPageOwnerMetricsCrossAccountAppName(t *testing.T) { fx := newConsoleFixture(t) if err := fx.st.CreateAccount(42, "cdef", "Rival", "rival@keymail.dev", time.Now()); err != nil { t.Fatal(err) } if _, err := fx.st.CreateAppIn(42, "kass", "rival@keymail.dev", time.Now()); err != nil { t.Fatal(err) } fx.addRoute(t, registry.Route{Host: "owned.example.com", Kind: "instance", Status: "ready", App: "kass", Backing: registry.BackingUnit}) // Same app name, different account — paul is not a member of cdef. fx.addRoute(t, registry.Route{Host: "rival.example.com", Kind: "instance", Status: "ready", App: "kass", Account: "cdef", Backing: registry.BackingUnit}) at := time.Now().UTC().Add(-time.Minute) if err := metrics.WriteRaw(context.Background(), fx.rel, metrics.RawChunk{ V: 1, Box: "box-shared", Hour: metrics.HourOf(at), Samples: []metrics.Sample{{ At: at, Units: map[string]metrics.UnitSample{ "carlos-app@owned.example.com.service": {CPUFrac: p(0.11), MemBytes: pi64(1024)}, "carlos-app@rival.example.com.service": {CPUFrac: p(0.33), MemBytes: pi64(8192)}, }, }}, }); err != nil { t.Fatal(err) } c := signInPaul(t, fx) status, _, body := get(t, c, fx.u("/apps/kass/instances")) if status != http.StatusOK { t.Fatalf("/apps/kass/instances = %d, want 200", status) } if !strings.Contains(body, "11%") { t.Fatalf("own instance's metrics missing in:\n%s", body) } // The other account's readings, by any spelling. for _, forbidden := range []string{"33%", "8.0 KB"} { if strings.Contains(body, forbidden) { t.Fatalf("app page leaked another account's reading %q in:\n%s", forbidden, body) } } // And the other account's host is not in the Resources section at all. // // NOTE: the assertion is scoped to Resources on purpose — this is the // /instances tab, which renders neither Domains nor the ladder, so // rival's host has nowhere else on THIS page to leak. It still leaks // on the settings and overview tabs; see the NOTE above TestAppSettingsTab. if res := resourcesSection(t, body); strings.Contains(res, "rival.example.com") { t.Fatalf("Resources section leaked another account's host:\n%s", res) } } // resourcesSection returns just the app page's Resources block. // resourcesSection used to cut out a dedicated "Resources" section; CPU/Mem // readings now live as columns on the Instances table itself, so this cuts // from that table's header to the end of the page's main content instead — // the same "just the per-route metrics chrome" scope, at its new address. func resourcesSection(t *testing.T, body string) string { t.Helper() i := strings.Index(body, "

Instances

") if i < 0 { t.Fatalf("no Instances section in:\n%s", body) } j := strings.Index(body[i:], "") if j < 0 { t.Fatalf("Running section never ended:\n%s", body[i:]) } return body[i : i+j] } // instanceRow returns the Running table's one naming the given host — // sliced from the host's own anchor through the row's closing tag. The // fixture always carries more than one route (its own bystanders plus // whatever a test adds), so a page-wide strings.Contains on a Health word // like "Not responding" or "Asleep" can be satisfied by SOME row without // pinning it to the row under test; scoping to the row is what actually // proves it. func instanceRow(t *testing.T, body, host string) string { t.Helper() i := strings.Index(body, host) if i < 0 { t.Fatalf("no row for host %q in:\n%s", host, body) } row := body[i:] j := strings.Index(row, "") if j < 0 { t.Fatalf("row for host %q never closed in:\n%s", host, row) } return row[:j] } // TestAppPageOwnerMetricsStaleBoxIsNotCurrent: a box that stopped publishing // keeps its last sample inside the 48h sweep window forever. Rendering that // as the instance's CURRENT figure would be a lie with no expiry, so a box // whose newest sample is older than the freshness window reads as no recent // readings — the same state a hibernating instance gets. func TestAppPageOwnerMetricsStaleBoxIsNotCurrent(t *testing.T) { fx := newConsoleFixture(t) fx.addRoute(t, registry.Route{Host: "owned.example.com", Kind: "instance", Status: "ready", App: "kass", Backing: registry.BackingUnit}) at := time.Now().UTC().Add(-3 * time.Hour) // inside the sweep, far outside fresh if err := metrics.WriteRaw(context.Background(), fx.rel, metrics.RawChunk{ V: 1, Box: "box-dead", Hour: metrics.HourOf(at), Samples: []metrics.Sample{{ At: at, Units: map[string]metrics.UnitSample{ "carlos-app@owned.example.com.service": {CPUFrac: p(0.23), MemBytes: pi64(2048)}, }, }}, }); err != nil { t.Fatal(err) } c := signInPaul(t, fx) status, _, body := get(t, c, fx.u("/apps/kass/instances")) if status != http.StatusOK { t.Fatalf("/apps/kass/instances = %d, want 200", status) } // The CPU/Mem cell reads the same "no data" a hibernating instance gets // (staleness is not distinguishable from any other non-reporting cause at // that cell); the route isn't hibernating, so the Health column must say // so plainly rather than claim Asleep. Scoped to owned.example.com's own // row: the fixture also carries kass.carlosapp.dev, an instance-kind // route that is never metered and never hibernating, which renders "No // readings" (it carries no Backing/Unit/Provisioned — expectsReadings // can't tell) on every run of this page regardless of what this test // does — a page-wide Contains would pass even with the wrong route // hibernating. row := instanceRow(t, body, "owned.example.com") if !strings.Contains(row, "Not responding") || !strings.Contains(row, "no data") { t.Fatalf("stale box did not render as not responding with no data:\n%s", row) } for _, forbidden := range []string{"23%", "2.0 KB"} { if strings.Contains(body, forbidden) { t.Fatalf("app page rendered a %v-old reading as current (%q):\n%s", time.Since(at).Truncate(time.Hour), forbidden, resourcesSection(t, body)) } } } // TestFleetMetricsRendersFiringAlertsWithUnits pins two things the alerts // section had never actually been rendered in a test for: it appears ABOVE // the box cards (an operator opening the page sees what is on fire before // scrolling anything), and each rule's value carries ITS OWN unit. Every // alert value used to render through the percent formatter, so the first lag // alert to fire — lag is seconds, not a fraction — would have read // "92000%" instead of "920s". func TestFleetMetricsRendersFiringAlertsWithUnits(t *testing.T) { fx := newConsoleFixture(t) ctx := context.Background() at := time.Now().UTC().Add(-2 * time.Minute) if err := metrics.WriteRaw(ctx, fx.rel, metrics.RawChunk{ V: 1, Box: "box-alerting", Hour: metrics.HourOf(at), Samples: []metrics.Sample{{At: at, Host: metrics.HostSample{CPUFrac: p(0.42), DiskFrac: p(0.95)}}}, }); err != nil { t.Fatal(err) } state := metrics.AlertState{ V: 1, Box: "box-alerting", UpdatedAt: at, Rules: map[string]metrics.RuleState{ "disk": {Firing: true, Value: 0.95, FiredAt: at}, "lag": {Firing: true, Value: 920, FiredAt: at}, }, } body, err := json.Marshal(state) if err != nil { t.Fatal(err) } if _, err := fx.rel.Put(ctx, metrics.AlertKey("box-alerting"), body, ""); err != nil { t.Fatal(err) } c := signInPaul(t, fx) if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { t.Fatal(err) } status, _, page := get(t, c, fx.ts.URL+"/fleet/metrics") if status != 200 { t.Fatalf("/fleet/metrics = %d: %s", status, page) } alertsAt := strings.Index(page, ">Alerts<") cardAt := strings.Index(page, "

box-alerting

") if alertsAt < 0 { t.Fatalf("no alerts section rendered for a firing box:\n%s", page) } if cardAt < 0 { t.Fatalf("no box card rendered:\n%s", page) } if alertsAt > cardAt { t.Errorf("alerts section renders after the first box card (%d > %d) — firing alerts belong at the top", alertsAt, cardAt) } if !strings.Contains(page, "95%") { t.Errorf("disk alert value not rendered as a percent:\n%s", page) } if !strings.Contains(page, "920s") { t.Errorf("lag alert value not rendered in seconds:\n%s", page) } if strings.Contains(page, "92000%") { t.Errorf("lag alert value rendered as a percentage — lag is seconds:\n%s", page) } } // TestReleasesPageShowsLabelsAndTags: the Releases tab's own read joins // Versions against ListTags (view.go's tagByRelease, release versioning // spec 2026-08-08) — a shipped iteration's label and notes are visible, and // once a tag has been cut for it, the tag pill and its changelog show too. func TestReleasesPageShowsLabelsAndTags(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) ctx := context.Background() if err := release.Ship(ctx, fx.rel, release.OpsAccount, release.Manifest{ Schema: 1, App: "kass", Version: "0.5.0-1", Kind: "binary", BuiltAt: "2026-08-08T10:00:00Z", Label: "fixing modals", Notes: "- x", Artifacts: map[string]release.Artifact{"linux-amd64": {File: "kass"}}, }, map[string][]byte{"linux-amd64": []byte("bin")}); err != nil { t.Fatal(err) } if _, _, err := release.EnsureTag(ctx, fx.rel, release.OpsAccount, "kass", "0.5.0-1", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } status, _, body := get(t, c, fx.u("/apps/kass/releases")) if status != http.StatusOK { t.Fatalf("/apps/kass/releases = %d", status) } for _, want := range []string{"fixing modals", "0.5.0-1", "- x"} { if !strings.Contains(body, want) { t.Fatalf("releases page missing %q in:\n%s", want, body) } } // The tag pill sits on the Releases table's Version cell as a chip, not // merely somewhere on the page. if !strings.Contains(body, `0.5.0`) { t.Fatalf("no tag pill for 0.5.0 in:\n%s", body) } // The Tags section below the Releases table carries the compiled // changelog — the same notes text, reached a different way. if !strings.Contains(body, "

Tags

") { t.Fatalf("no Tags section in:\n%s", body) } } // TestChannelRowShowsStableTag: a channel whose current version carries a // tag shows it next to the version in the Channels table. func TestChannelRowShowsStableTag(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) shipVersion(t, fx, "kass", "0.6.0-1") seedChannel(t, fx, "kass", "stable", "0.6.0-1") if _, _, err := release.EnsureTag(context.Background(), fx.rel, release.OpsAccount, "kass", "0.6.0-1", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } _, _, body := get(t, c, fx.u("/apps/kass/releases")) if !strings.Contains(body, `0.6.0-1 0.6.0`) { t.Fatalf("stable channel row missing tag pill for 0.6.0:\n%s", body) } } // TestPromoteFlashNamesTag drives the browser promote-to-stable path (the // same ladder walk TestPromoteFlow uses) with an iteration-shaped version, // and checks the flash names the tag PromoteOnLadder cut. The repeat // promotion is the CARRY-FORWARD binding from the task-10 brief: a no-op // still names the tag, because PromoteOnLadder's stableTag re-cuts (or // heals) it on every stable landing, no-op included, and a flash that went // quiet on a repeat would hide a healed tag from the operator who just // asked for one. func TestPromoteFlashNamesTag(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) shipVersion(t, fx, "kass", "0.7.0-1") postForm(t, c, fx.u("/apps/kass/promote"), url.Values{"csrf": {csrf}, "version": {"0.7.0-1"}, "new_channel": {"edge"}, "confirmed": {"1"}}) postForm(t, c, fx.u("/apps/kass/promote"), url.Values{"csrf": {csrf}, "version": {"0.7.0-1"}, "new_channel": {"beta"}, "confirmed": {"1"}}) postGated(t, fx, c, fx.u("/apps/kass/promote"), url.Values{"csrf": {csrf}, "version": {"0.7.0-1"}, "channel": {"stable"}, "confirmed": {"1"}}) if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "tagged 0.7.0") { t.Fatalf("promote-to-stable flash missing tag name:\n%s", body) } postGated(t, fx, c, fx.u("/apps/kass/promote"), url.Values{"csrf": {csrf}, "version": {"0.7.0-1"}, "channel": {"stable"}, "confirmed": {"1"}}) 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") { t.Fatalf("no-op stable promote flash missing tag name:\n%s", body) } } // TestPromoteFlashNamesTagWarning is the other half of the binding // constraint: a version that reaches stable but is NOT a "-" // iteration (a sha build, an explicit -version ship) carries no tag — // stableTag sets res.TagWarning instead of res.Tag (internal/release's // release.go), and the flash must say why, in parentheses, exactly as // tagSuffix builds it, not go silent about it. func TestPromoteFlashNamesTagWarning(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) shipVersion(t, fx, "kass", "v9") postForm(t, c, fx.u("/apps/kass/promote"), url.Values{"csrf": {csrf}, "version": {"v9"}, "new_channel": {"edge"}, "confirmed": {"1"}}) postForm(t, c, fx.u("/apps/kass/promote"), url.Values{"csrf": {csrf}, "version": {"v9"}, "new_channel": {"beta"}, "confirmed": {"1"}}) postGated(t, fx, c, fx.u("/apps/kass/promote"), url.Values{"csrf": {csrf}, "version": {"v9"}, "channel": {"stable"}, "confirmed": {"1"}}) _, _, body := get(t, c, fx.u("/apps/kass")) // html/template escapes the flash text, so the literal angle brackets in // TagWarning's "-" arrive as entities. if !strings.Contains(body, "(no tag cut: v9 is not a <target>-<n> iteration)") { t.Fatalf("promote-to-stable flash missing tag warning:\n%s", body) } if strings.Contains(body, " — tagged ") { t.Fatalf("flash claims a tag was cut for a non-iteration version:\n%s", body) } } // TestReleasesPageNoTagsSection: an app with no tags cut renders 200 and // omits the Tags section entirely — {{if .Tags}} must not print an empty // heading, and a nil/empty p.Tags must not be confused with a read failure. func TestReleasesPageNoTagsSection(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) status, _, body := get(t, c, fx.u("/apps/kass/releases")) if status != http.StatusOK { t.Fatalf("/apps/kass/releases = %d, want 200", status) } if strings.Contains(body, "

Tags

") { t.Fatalf("Tags section rendered with no tags ever cut:\n%s", body) } } // newRedirectFixture builds the minimal Server the CONSOLE_REDIRECT_HOSTS // tests need — same trimmed shape as newMetricsFixture (metricsview_test.go), // with none of consoleFixture's registry/keymail/HTTP-listener scaffolding, // but a caller-chosen Origin and RedirectHosts since that's what's under // test here. func newRedirectFixture(t *testing.T, origin string, redirectHosts []string) *Server { t.Helper() st, err := Open(filepath.Join(t.TempDir(), "console.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) rel := release.NewDirStore(filepath.Join(t.TempDir(), "bucket")) srv, err := NewServer(Config{Releases: rel, Origin: origin, RedirectHosts: redirectHosts}, st) if err != nil { t.Fatal(err) } return srv } // serveRedirect drives srv.ServeHTTP directly with a chosen Host header — no // httptest.Server/listener needed, since the redirect check reads r.Host // before the mux (or anything network-shaped) is involved. func serveRedirect(t *testing.T, srv *Server, method, host, target string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, target, nil) req.Host = host rec := httptest.NewRecorder() srv.ServeHTTP(rec, req) return rec } func TestRedirectHostsPermanentRedirect(t *testing.T) { srv := newRedirectFixture(t, "https://console.example.test", []string{"dashboard.example.test"}) rec := serveRedirect(t, srv, http.MethodGet, "dashboard.example.test", "/a/foo?x=1") if rec.Code != http.StatusMovedPermanently { t.Fatalf("GET on a redirect host = %d, want 301", rec.Code) } if loc := rec.Header().Get("Location"); loc != "https://console.example.test/a/foo?x=1" { t.Fatalf("Location = %q, want the canonical origin with the request untouched", loc) } } // TestRedirectHostsPreservesMethodWith308 pins the 308 choice for anything // other than GET/HEAD: a 301/302 lets browsers replay a POST as a GET and // drop its body, which is exactly the client breakage RedirectHosts must not // cause on a host still fielding real requests. func TestRedirectHostsPreservesMethodWith308(t *testing.T) { srv := newRedirectFixture(t, "https://console.example.test", []string{"dashboard.example.test"}) rec := serveRedirect(t, srv, http.MethodPost, "dashboard.example.test", "/a/foo?x=1") if rec.Code != http.StatusPermanentRedirect { t.Fatalf("POST on a redirect host = %d, want 308 (keeps the method and body on replay)", rec.Code) } if loc := rec.Header().Get("Location"); loc != "https://console.example.test/a/foo?x=1" { t.Fatalf("Location = %q, want the canonical origin", loc) } } func TestRedirectHostsMatchesHostWithPort(t *testing.T) { srv := newRedirectFixture(t, "https://console.example.test", []string{"dashboard.example.test"}) rec := serveRedirect(t, srv, http.MethodGet, "dashboard.example.test:443", "/a/foo?x=1") if rec.Code != http.StatusMovedPermanently { t.Fatalf("Host header carrying a port = %d, want 301 — the port must not defeat the match", rec.Code) } if loc := rec.Header().Get("Location"); loc != "https://console.example.test/a/foo?x=1" { t.Fatalf("Location = %q, want the canonical origin", loc) } } // TestRedirectHostsLeavesOtherHostsAlone pins the opt-in contract: a host // that was never listed (here, the console's own origin host) gets the // route it would always get, not a redirect — this is what keeps // platform.carlosframework.com serving CLI bearer tokens untouched. func TestRedirectHostsLeavesOtherHostsAlone(t *testing.T) { srv := newRedirectFixture(t, "https://console.example.test", []string{"dashboard.example.test"}) rec := serveRedirect(t, srv, http.MethodGet, "console.example.test", "/healthz") if rec.Code == http.StatusMovedPermanently || rec.Code == http.StatusPermanentRedirect { t.Fatalf("an unlisted host was redirected: %d -> %q", rec.Code, rec.Header().Get("Location")) } if rec.Code != 200 || !strings.Contains(rec.Body.String(), "ok") { t.Fatalf("unlisted host = %d %q, want /healthz served normally", rec.Code, rec.Body.String()) } } // TestNewServerRefusesRedirectingItsOwnOrigin pins the loop guard: a // RedirectHosts entry equal to Origin's own host would 301/308 a request // straight back to the host that just answered it. func TestNewServerRefusesRedirectingItsOwnOrigin(t *testing.T) { st, err := Open(filepath.Join(t.TempDir(), "console.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) rel := release.NewDirStore(filepath.Join(t.TempDir(), "bucket")) _, err = NewServer(Config{ Releases: rel, Origin: "https://console.example.test", RedirectHosts: []string{"console.example.test"}, }, st) if err == nil { t.Fatal("NewServer accepted a RedirectHosts entry equal to its own origin host — that config would redirect-loop") } } // TestNewServerRefusesRedirectHostsWithoutAnOriginHost pins the other half of // the loop guard: an origin it cannot read a host out of (here the classic // operator slip, a CONSOLE_ORIGIN with no scheme) leaves the guard with // nothing to compare against AND leaves every Location without a host of its // own. Refuse at start rather than serve half a redirect. func TestNewServerRefusesRedirectHostsWithoutAnOriginHost(t *testing.T) { for _, origin := range []string{"", "console.example.test"} { st, err := Open(filepath.Join(t.TempDir(), "console.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) rel := release.NewDirStore(filepath.Join(t.TempDir(), "bucket")) if _, err := NewServer(Config{ Releases: rel, Origin: origin, RedirectHosts: []string{"dashboard.example.test"}, }, st); err == nil { t.Errorf("NewServer accepted RedirectHosts with Origin %q — there is no host to redirect to", origin) } } } // TestRedirectHostsRootlessTargetStaysOnTheOrigin pins the Location against a // request target that isn't a path. Go parses `GET mailto:x@evil.com` into an // opaque URL, and origin+RequestURI would then read // https://console.example.test@evil.com — userinfo, not host — handing an // outsider a redirect off the deployment with the console's name on the // front of it. func TestRedirectHostsRootlessTargetStaysOnTheOrigin(t *testing.T) { srv := newRedirectFixture(t, "https://console.example.test", []string{"dashboard.example.test"}) for _, target := range []string{"mailto:x@evil.com", "https:evil.com"} { rec := serveRedirect(t, srv, http.MethodGet, "dashboard.example.test", target) loc := rec.Header().Get("Location") if loc != "https://console.example.test/" { t.Errorf("target %q redirected to %q, want the origin's front door", target, loc) } } } func TestSummarizeVerdictAndURL(t *testing.T) { now := time.Now() v := AppView{App: "kass", Routes: []RouteView{ {Host: "kass.bab.oncarlos.com", Account: "bab", Kind: "instance", Status: "ready", Version: "d388035"}, {Host: "kass.example.com", Account: "bab", Kind: "instance", Status: "ready", Version: "d388035"}, }, Channels: []ChannelView{ {Name: "stable", Version: "d388035", PromotedAt: now.Add(-2 * time.Hour)}, {Name: "canary/rc-1", Version: "aaa1111"}, }} row := summarize(v, "bab") if row.URL != "kass.example.com" { t.Errorf("URL = %q, want the apex host kass.example.com", row.URL) } if row.Verdict != "ok" || row.VerdictLabel != "Deployed" { t.Errorf("verdict = %q/%q, want ok/Deployed", row.Verdict, row.VerdictLabel) } if row.CanaryN != 1 { t.Errorf("CanaryN = %d, want 1", row.CanaryN) } if row.Deployment.UniformVersion() != "d388035" { t.Errorf("observed version = %q, want d388035", row.Deployment.UniformVersion()) } v.Routes[0].Status = "failed" row = summarize(v, "bab") if row.Verdict != "attention" || row.VerdictLabel != "Needs attention" { t.Errorf("failed route: verdict = %q/%q, want attention/Needs attention", row.Verdict, row.VerdictLabel) } empty := summarize(AppView{App: "new"}, "bab") if empty.Verdict != "notdeployed" || empty.VerdictLabel != "Not deployed yet" { t.Errorf("no stable: verdict = %q/%q, want notdeployed/Not deployed yet", empty.Verdict, empty.VerdictLabel) } edgeOnly := summarize(AppView{App: "edge-only", Routes: []RouteView{ {Host: "edge.example", Account: "bab", Channel: "edge", Version: "v9"}, }}, "bab") if edgeOnly.Verdict != "ok" || edgeOnly.Deployment.UniformVersion() != "v9" { t.Errorf("edge-only route = %+v, want deployed at its observed v9", edgeOnly) } mixed := summarize(AppView{App: "mixed", Routes: []RouteView{ {Host: "a.example", Account: "bab", Channel: "production", Version: "v1"}, {Host: "b.example", Account: "bab", Channel: "canary/live", Version: "v2"}, }}, "bab") if mixed.Verdict != "ok" || !mixed.Deployment.Mixed() || mixed.Deployment.UniformVersion() != "" { t.Errorf("mixed deployment = %+v", mixed) } partial := summarize(AppView{App: "partial", Routes: []RouteView{ {Host: "a.example", Account: "bab", Version: "v1"}, {Host: "b.example", Account: "bab"}, }}, "bab") if partial.Verdict != "unknown" { t.Errorf("partial deployment verdict = %q, want unknown", partial.Verdict) } obsolete := summarize(AppView{App: "obsolete", Channels: []ChannelView{{Name: "stable", Version: "old"}}}, "bab") if obsolete.Verdict != "notdeployed" || obsolete.Deployment.UniformVersion() != "" { t.Errorf("obsolete stable target = %+v, want not deployed", obsolete) } bad := summarize(AppView{App: "kass", Routes: []RouteView{{Host: "x", Account: "bab"}}, Channels: []ChannelView{{Name: "stable", Err: "unreadable"}}}, "bab") if bad.Verdict != "unknown" || bad.VerdictLabel != "Can't tell right now" { t.Errorf("unknown route report: verdict = %q/%q, want unknown/Can't tell right now", bad.Verdict, bad.VerdictLabel) } // A foreign account's route must not draw this row's URL or trip its // verdict — draws the tenant boundary summarize is supposed to enforce. foreign := AppView{App: "kass", Routes: []RouteView{ {Host: "kass.bab.oncarlos.com", Account: "bab", Kind: "instance", Status: "ready", Version: "edge-v"}, {Host: "a.a", Account: "other", Kind: "instance", Status: "failed"}, }, Channels: []ChannelView{ {Name: "stable", Version: "d388035", PromotedAt: now.Add(-2 * time.Hour)}, }} row = summarize(foreign, "bab") if row.Verdict != "ok" || row.VerdictLabel != "Deployed" { t.Errorf("foreign failed route leaked into verdict: %q/%q, want ok/Deployed", row.Verdict, row.VerdictLabel) } if row.URL != "kass.bab.oncarlos.com" { t.Errorf("URL = %q, want the own-account host kass.bab.oncarlos.com, not the foreign apex-ier a.a", row.URL) } } func TestNormalizeHost(t *testing.T) { for _, tc := range []struct{ in, want string }{ {"dashboard.example.test", "dashboard.example.test"}, {"DASHBOARD.Example.Test:443", "dashboard.example.test"}, {" dashboard.example.test ", "dashboard.example.test"}, // An IPv6 literal keys the same with or without its port, which is // only true if the portless form loses its brackets too. {"[::1]", "::1"}, {"[::1]:8080", "::1"}, } { if got := normalizeHost(tc.in); got != tc.want { t.Errorf("normalizeHost(%q) = %q, want %q", tc.in, got, tc.want) } } } // A deployment that sets CARLOS_CONSOLE_NAME reads as itself everywhere a // person looks: the front page, the tab title, the header and the new // account's welcome. Unset, the CARLOS names stay (TestEmptyStateWelcomesNewUser). func TestConsoleNameBrandsThePages(t *testing.T) { fx := newConsoleFixture(t) fx.srv.consoleNameCfg = "Carloku" _, _, front := get(t, newBrowser(t), fx.ts.URL+"/") for _, want := range []string{"🤖 Carloku", "· Carloku", `Carloku`} { if !strings.Contains(front, want) { t.Fatalf("front page missing %q", want) } } if strings.Contains(front, "CARLOS console") { t.Fatal("front page still says CARLOS console") } fx.setUser("bob@keymail.dev") c := newBrowser(t) signUp(t, fx, c, "code-b") _, home, _ := get(t, c, fx.ts.URL+"/") _, _, body := get(t, c, fx.ts.URL+home) // Inside an account the header is the account switcher, not the brand. for _, want := range []string{"Welcome to Carloku", "· Carloku"} { if !strings.Contains(body, want) { t.Fatalf("account home missing %q", want) } } if strings.Contains(body, "CARLOS platform") || strings.Contains(body, "CARLOS console") { t.Fatal("account home still names CARLOS") } } // TestFleetShowsDisplayNames: both tables on /fleet show a named box as its // name with the label muted beside it, an unnamed box exactly as before, and // a box-written name escaped like every other string a box supplies. func TestFleetShowsDisplayNames(t *testing.T) { fx := newConsoleFixture(t) ctx := context.Background() now := time.Now() for _, b := range []status.Box{ {GeneratedAt: now, Label: "zed-x", Name: "aoife-herding-ie"}, {GeneratedAt: now, Label: "abe-y"}, {GeneratedAt: now, Label: "evil-z", Name: "bold"}, } { if err := status.WriteBox(ctx, fx.rel, b); err != nil { t.Fatal(err) } } f, err := status.BuildFleet(ctx, fx.rel, nil, now, status.SnapshotPeriod) if err != nil { t.Fatal(err) } if err := status.PublishFleet(ctx, fx.rel, f); err != nil { t.Fatal(err) } c := signInPaul(t, fx) if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { t.Fatal(err) } code, _, body := get(t, c, fx.ts.URL+"/fleet") if code != 200 { t.Fatalf("/fleet = %d: %s", code, body) } named := `aoife-herding-ie zed-x` if n := strings.Count(body, named); n != 2 { t.Errorf("named cell %q appears %d times, want 2 (Boxes and Hosts):\n%s", named, n, body) } if n := strings.Count(body, "abe-y"); n != 2 { t.Errorf("unnamed cell appears %d times, want 2:\n%s", n, body) } if strings.Contains(body, "bold") { t.Errorf("a box-written name rendered as markup:\n%s", body) } }