carlos / platform Public

console: tests follow the app-pages redesign; rename's guard matches the schema

The existing suite follows the two intentional behavior changes — the
ladder lives on Releases now, and executing an action takes confirmed=1
past its card — with every security assertion kept: owner 404s stay
byte-identical, step-up still gates stable/fast-track, and the
enable_bake checkbox is asserted on the confirm card it moved to. New
coverage for the confirm interstitials (card-before-step-up, no card for
members probing owner actions), the Settings actions (rename refusals on
shipped/routed/taken/legacy-granted names, delete's exact-name + passkey
+ production refusals, the move repair and its non-member refusal), and
the overview/instances assemblers.

Writing those tests caught a real bug: RenameAppIn guarded the UPDATE
with deleted_at IS NULL, but the column is NOT NULL DEFAULT 0 — no
rename could ever succeed. It now spells the guard the way the rest of
the file does (deleted_at = 0). Also from screenshot verification: the
Overview activity feed named Event fields that don't exist (Actor/At for
Address/CreatedAt), which killed template execution mid-page; the
six stat tiles render as an even grid instead of flex stragglers; and
inline forms (the bake toggle) stay inside their sentence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d2f33eb0bba793d01ffa06a33802c31c8fae1dfd parent 2532718
11 files changed, +1543 −82
  • internal/console/appsettings_test.go +657 −0
  • internal/console/bakecontrols_test.go +6 −6
  • internal/console/confirm_test.go +314 −0
  • internal/console/ladder_test.go +44 −32
  • internal/console/metricsview_test.go +162 −0
  • internal/console/overview_test.go +279 −0
  • internal/console/server_account_test.go +19 −5
  • internal/console/server_test.go +52 −36
  • internal/console/store.go +1 −1
  • internal/console/templates/app_overview.html +2 −2
  • internal/console/templates/layout.html +7 −0
diff --git a/internal/console/appsettings_test.go b/internal/console/appsettings_test.go
new file mode 100644
index 000000000..aeea374d9
--- /dev/null
+++ b/internal/console/appsettings_test.go
@@ -0,0 +1,657 @@
+package console
+
+// The app Settings tab's write half (appsettings.go): rename, delete, and the
+// account-move repair, plus the store verb rename is built on.
+//
+// The theme running through all of it is that this console may only change its
+// OWN record. Every test here therefore checks two things about a refusal: that
+// the person is told, and that nothing outside this database moved — because
+// the whole reason renaming is refused after a ship, and deletion is refused on
+// a production app, is that the other stores would not follow.
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// ---- Store.RenameAppIn ------------------------------------------------------
+
+// renameStore is an account with one app in it — the smallest world a rename
+// means anything in.
+func renameStore(t *testing.T, app string) (*Store, int64) {
+ t.Helper()
+ s := openTestStore(t)
+ id := testAccountID()
+ if err := s.CreateAccount(id, "rn"+app, "Renameco", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if created, err := s.CreateAppIn(id, app, "paul@keymail.dev", time.Now()); err != nil || !created {
+ t.Fatalf("CreateAppIn = %v %v", created, err)
+ }
+ return s, id
+}
+
+// The happy path, and the half that is easy to forget: the app's audit history
+// follows it. Its story belongs to the app, not to a name that no longer
+// resolves — a rename that stranded the trail would leave the Activity tab of
+// the renamed app blank and the events unreachable under any name.
+//
+// RED as written (2026-08-07): RenameAppIn's UPDATE is guarded by
+// `deleted_at IS NULL`, and apps.deleted_at is `INTEGER NOT NULL DEFAULT 0` —
+// so the guard matches no row, ever, and every rename returns "this account
+// does not claim %q". The guard the rest of the file uses for "live" is
+// `deleted_at = 0`.
+func TestRenameAppInStoreRenamesRowAndAudit(t *testing.T) {
+ s, id := renameStore(t, "alpha")
+ if err := s.RecordEvent(id, "alpha", "paul@keymail.dev", "create", "created app alpha", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := s.RenameAppIn(id, "alpha", "beta"); err != nil {
+ t.Fatalf("RenameAppIn(alpha -> beta) = %v, want the rename to land", err)
+ }
+ if has, err := s.HasAppIn(id, "beta"); err != nil || !has {
+ t.Fatalf("HasAppIn(beta) = %v %v, want the row under its new name", has, err)
+ }
+ if has, err := s.HasAppIn(id, "alpha"); err != nil || has {
+ t.Fatalf("HasAppIn(alpha) = %v %v, want the old name gone", has, err)
+ }
+ moved, err := s.Events(id, "beta", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(moved) != 1 || moved[0].Detail != "created app alpha" {
+ t.Fatalf("Events(beta) = %+v, want the app's history to have followed it", moved)
+ }
+ left, err := s.Events(id, "alpha", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(left) != 0 {
+ t.Fatalf("Events(alpha) = %+v, want nothing left under the old name", left)
+ }
+}
+
+// The name must be free in this account — and a TRASHED row still owns its
+// name until it is purged, exactly as CreateAppIn treats it. Renaming onto one
+// would resurrect a collision the moment the trashed app was restored.
+func TestRenameAppInRefusesTakenNames(t *testing.T) {
+ s, id := renameStore(t, "alpha")
+ if _, err := s.CreateAppIn(id, "live", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.CreateAppIn(id, "binned", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.TrashAppRow(id, "binned", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ for _, to := range []string{"live", "binned"} {
+ err := s.RenameAppIn(id, "alpha", to)
+ if err == nil {
+ t.Fatalf("RenameAppIn(alpha -> %s) succeeded onto a claimed name", to)
+ }
+ if !strings.Contains(err.Error(), "already has an app") {
+ t.Fatalf("RenameAppIn(alpha -> %s) = %v, want the taken-name refusal", to, err)
+ }
+ // And the refusal is total: nothing moved.
+ if has, err := s.HasAppIn(id, "alpha"); err != nil || !has {
+ t.Fatalf("HasAppIn(alpha) = %v %v after a refused rename, want it untouched", has, err)
+ }
+ }
+}
+
+// Legacy owners-table grants are keyed by BARE app name across all accounts, so
+// rewriting one could hand an account's grant to another, and renaming ONTO one
+// would let the new name inherit a grant nobody issued it. Both directions are
+// refused.
+func TestRenameAppInRefusesLegacyGrants(t *testing.T) {
+ s, id := renameStore(t, "alpha")
+ if err := s.Grant("paul@keymail.dev", "alpha"); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.RenameAppIn(id, "alpha", "gamma"); err == nil ||
+ !strings.Contains(err.Error(), "legacy owner grant") {
+ t.Fatalf("renaming FROM a granted name = %v, want the legacy-grant refusal", err)
+ }
+ if err := s.Revoke("paul@keymail.dev", "alpha"); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Grant("paul@keymail.dev", "gamma"); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.RenameAppIn(id, "alpha", "gamma"); err == nil ||
+ !strings.Contains(err.Error(), "legacy owner grant") {
+ t.Fatalf("renaming ONTO a granted name = %v, want the legacy-grant refusal", err)
+ }
+ if has, err := s.HasAppIn(id, "alpha"); err != nil || !has {
+ t.Fatalf("HasAppIn(alpha) = %v %v after the refusals, want it untouched", has, err)
+ }
+}
+
+// Renaming something this account does not claim is an error, not a silent
+// no-op — the handler turns it into "could not rename", and a quiet success
+// would tell the owner an app moved when nothing did.
+func TestRenameAppInRefusesMissingSource(t *testing.T) {
+ s, id := renameStore(t, "alpha")
+ err := s.RenameAppIn(id, "ghost", "gamma")
+ if err == nil {
+ t.Fatal("renaming an app this account does not claim reported success")
+ }
+ if !strings.Contains(err.Error(), "does not claim") {
+ t.Fatalf("RenameAppIn(ghost -> gamma) = %v, want the unclaimed-source refusal", err)
+ }
+ if has, err := s.HasAppIn(id, "gamma"); err != nil || has {
+ t.Fatalf("HasAppIn(gamma) = %v %v, want no row conjured by the failed rename", has, err)
+ }
+}
+
+// ---- handleAppRename --------------------------------------------------------
+
+// freshApp claims an app name in the fixture account with nothing behind it:
+// no bucket keys, no routes. That is the ONLY state a rename is offered in, so
+// it is the state most of these tests start from.
+func freshApp(t *testing.T, fx *consoleFixture, app string) {
+ t.Helper()
+ if created, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", time.Now()); err != nil || !created {
+ t.Fatalf("CreateAppIn(%s) = %v %v", app, created, err)
+ }
+}
+
+// shipSomething puts one object under the app's bucket prefix. Which object is
+// beside the point: appDeployEmpty refuses on ANY key, because one key is
+// enough to prove the name is baked into a storage layer this handler cannot
+// rewrite.
+func shipSomething(t *testing.T, fx *consoleFixture, app string) {
+ t.Helper()
+ key := release.AppPrefix(fx.sqid, app) + "releases/v1/manifest.json"
+ if _, err := fx.rel.Put(context.Background(), key, []byte(`{"schema":1}`), ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// The card, then the rename: a fresh app's name is still only a record here,
+// so the whole ceremony is confirm-and-go, with no passkey.
+//
+// RED as written (2026-08-07) for the same reason as
+// TestRenameAppInStoreRenamesRowAndAudit: the store verb underneath cannot
+// match a live row, so the confirmed POST flashes "Could not rename" and
+// lands back on the OLD settings URL. The card half of this test is green.
+func TestRenameHandlerConfirmsThenRenames(t *testing.T) {
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "sprout")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ form := url.Values{"csrf": {csrf}, "to": {"sapling"}}
+
+ status, loc, body := postForm(t, c, fx.u("/apps/sprout/rename"), form)
+ if strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Fatalf("renaming a never-shipped app demanded a passkey (-> %q)", loc)
+ }
+ card := confirmCard(t, status, body, "Rename sprout to sapling?", csrf)
+ if !strings.Contains(card, `<input type="hidden" name="to" value="sapling">`) {
+ t.Fatalf("the card does not carry the new name:\n%s", card)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "sprout"); err != nil || !has {
+ t.Fatalf("HasAppIn(sprout) = %v %v after the card, want it unrenamed", has, err)
+ }
+
+ form.Set("confirmed", "1")
+ status, loc, body = postForm(t, c, fx.u("/apps/sprout/rename"), form)
+ if status != http.StatusSeeOther || loc != fx.path("/apps/sapling/settings") {
+ t.Fatalf("confirmed rename = %d -> %q, want 303 to the NEW settings URL:\n%s", status, loc, body)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "sapling"); err != nil || !has {
+ t.Fatalf("HasAppIn(sapling) = %v %v, want the app under its new name", has, err)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "sprout"); err != nil || has {
+ t.Fatalf("HasAppIn(sprout) = %v %v, want the old name gone", has, err)
+ }
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "Renamed sprout to sapling.") {
+ t.Fatalf("no rename flash:\n%s", page)
+ }
+ auditedBy(t, fx, "sapling", "rename", "paul@keymail.dev", "renamed sprout to sapling")
+}
+
+// One key under the app's prefix is enough. The refusal is a flash, not a card:
+// the handler decided before asking, so there is nothing to confirm.
+func TestRenameHandlerRefusesShippedApp(t *testing.T) {
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "shipped")
+ shipSomething(t, fx, "shipped")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, body := postForm(t, c, fx.u("/apps/shipped/rename"),
+ url.Values{"csrf": {csrf}, "to": {"renamed"}, "confirmed": {"1"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/shipped/settings") {
+ t.Fatalf("rename of a shipped app = %d -> %q, want the flash redirect:\n%s", status, loc, body)
+ }
+ notACard(t, body)
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "This app has shipped") {
+ t.Fatalf("no shipped refusal flash:\n%s", page)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "shipped"); err != nil || !has {
+ t.Fatalf("HasAppIn(shipped) = %v %v, want the app untouched", has, err)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "renamed"); err != nil || has {
+ t.Fatalf("HasAppIn(renamed) = %v %v, want no new row", has, err)
+ }
+}
+
+// A route wearing the name is the other wider store: the name is the address.
+// Checked at ACTION time, not from the page's advice — a route added since the
+// page rendered must still stop the rename.
+func TestRenameHandlerRefusesRoutedApp(t *testing.T) {
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "wired")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ // Added AFTER the app was claimed and with nothing in the bucket, so the
+ // deploy-empty check passes and the ROUTE check is what refuses.
+ fx.addRoute(t, registry.Route{Host: "wired.carlosapp.dev", Kind: "instance", Status: "ready",
+ App: "wired", Channel: "stable", Addr: "127.0.0.1:9031"})
+
+ status, loc, body := postForm(t, c, fx.u("/apps/wired/rename"),
+ url.Values{"csrf": {csrf}, "to": {"unwired"}, "confirmed": {"1"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/wired/settings") {
+ t.Fatalf("rename of a routed app = %d -> %q:\n%s", status, loc, body)
+ }
+ notACard(t, body)
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "A host still routes this name") {
+ t.Fatalf("no routed refusal flash:\n%s", page)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "unwired"); err != nil || has {
+ t.Fatalf("HasAppIn(unwired) = %v %v, want no rename", has, err)
+ }
+}
+
+// Renaming is the owner's. A member meets the byte-identical 404 — before any
+// card could concede the action exists.
+func TestRenameHandlerIsOwnerOnly(t *testing.T) {
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "sprout")
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("sam@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-sam-rename")
+ csrf := sessionToken(t, fx, c)
+
+ status, _, memberBody := postForm(t, c, fx.u("/apps/sprout/rename"),
+ url.Values{"csrf": {csrf}, "to": {"sapling"}})
+ if status != http.StatusNotFound {
+ t.Fatalf("member renaming = %d, want 404", status)
+ }
+ notACard(t, memberBody)
+ strangerBody := strangerRefusal(t, fx, "code-zoe-rename", fx.u("/apps/sprout/rename"),
+ url.Values{"to": {"sapling"}})
+ if memberBody != strangerBody {
+ t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "sapling"); err != nil || has {
+ t.Fatalf("HasAppIn(sapling) = %v %v, want no rename", has, err)
+ }
+}
+
+// CSRF is checked before ownership and before anything is read — a cross-site
+// POST is refused with 403 and never reaches the store.
+func TestRenameHandlerNeedsCSRF(t *testing.T) {
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "sprout")
+ c := signInPaul(t, fx)
+
+ status, _, body := postForm(t, c, fx.u("/apps/sprout/rename"),
+ url.Values{"csrf": {"not-the-token"}, "to": {"sapling"}, "confirmed": {"1"}})
+ if status != http.StatusForbidden {
+ t.Fatalf("rename with a bad csrf = %d, want 403:\n%s", status, body)
+ }
+ if !strings.Contains(body, "Cross-site request blocked.") {
+ t.Fatalf("403 body does not name the reason:\n%s", body)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "sapling"); err != nil || has {
+ t.Fatalf("HasAppIn(sapling) = %v %v, want no rename", has, err)
+ }
+}
+
+// ---- handleAppDelete --------------------------------------------------------
+
+// appTombstone reads the bucket's deletion marker — the thing that actually
+// makes a host stop serving. The console row alone is not a deletion.
+func appTombstone(t *testing.T, fx *consoleFixture, app string) *release.AppTombstone {
+ t.Helper()
+ tomb, err := release.ReadAppTombstone(context.Background(), fx.rel, fx.sqid, app)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return tomb
+}
+
+// Typing the name is a "did you mean this app?" control, checked BEFORE the
+// passkey so a slip does not cost a passkey touch — and, more to the point,
+// does not delete anything.
+func TestAppDeleteRequiresTheExactName(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, body := postForm(t, c, fx.u("/apps/kass/delete"),
+ url.Values{"csrf": {csrf}, "confirm": {"kas"}})
+ if status != http.StatusSeeOther || strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Fatalf("mistyped delete = %d -> %q, want a flash and no passkey demand:\n%s", status, loc, body)
+ }
+ if loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("mistyped delete redirected to %q, want the Settings tab", loc)
+ }
+ // The apostrophe in "app's" is HTML-escaped on the way out, so the
+ // assertion takes the unambiguous tail of the sentence.
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "name exactly to delete it.") {
+ t.Fatalf("no mistyped-name flash:\n%s", page)
+ }
+ if tomb := appTombstone(t, fx, "kass"); tomb != nil {
+ t.Fatalf("a mistyped delete wrote a trash marker: %+v", tomb)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
+ t.Fatalf("HasAppIn = %v %v, want the row live", has, err)
+ }
+ var stashed int
+ fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&stashed)
+ if stashed != 0 {
+ t.Fatal("a mistyped delete parked a pending action — the passkey was asked for anyway")
+ }
+}
+
+// The whole ceremony: the name, then the passkey, then the marker and the row,
+// in that order, landing on the account home.
+func TestAppDeleteTrashesBehindThePasskey(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ form := url.Values{"csrf": {csrf}, "confirm": {"kass"}}
+
+ // Without the passkey, nothing moves. (postForm, not postGated: the
+ // un-stepped-up post is what proves the gate is there at all.)
+ status, loc, _ := postForm(t, c, fx.u("/apps/kass/delete"), form)
+ if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Fatalf("un-stepped-up delete = %d -> %q, want a redirect into Keymail", status, loc)
+ }
+ if tomb := appTombstone(t, fx, "kass"); tomb != nil {
+ t.Fatalf("a trash marker was written before the passkey confirmation: %+v", tomb)
+ }
+
+ status, loc, body := completeStepUp(t, fx, c, loc)
+ if status != http.StatusSeeOther || loc != fx.path("/") {
+ t.Fatalf("resumed delete = %d -> %q, want 303 to the account home:\n%s", status, loc, body)
+ }
+ tomb := appTombstone(t, fx, "kass")
+ if tomb == nil {
+ t.Fatal("no trash marker in the bucket — hosts would go on serving the app")
+ }
+ if tomb.Actor != "paul@keymail.dev" {
+ t.Fatalf("marker actor = %q, want the owner who deleted it", tomb.Actor)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || has {
+ t.Fatalf("HasAppIn = %v %v, want the live row gone", has, err)
+ }
+ if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has {
+ t.Fatalf("HasTrashedAppIn = %v %v, want the row in the trash", has, err)
+ }
+ auditedBy(t, fx, "kass", "trash", "paul@keymail.dev", "moved kass to the trash")
+}
+
+// Production outranks deletion, all the way through the ceremony: the flag is
+// read after the passkey, and refuses even a fully confirmed owner.
+func TestAppDeleteRefusesAProductionApp(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := release.SetProduction(context.Background(), fx.rel, fx.priv,
+ release.OpsAccount, "kass", true, "test"); err != nil {
+ t.Fatal(err)
+ }
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/delete"),
+ url.Values{"csrf": {csrf}, "confirm": {"kass"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("delete of a production app = %d -> %q:\n%s", status, loc, body)
+ }
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "is flagged production, so deleting is refused") {
+ t.Fatalf("no production refusal flash:\n%s", page)
+ }
+ if tomb := appTombstone(t, fx, "kass"); tomb != nil {
+ t.Fatalf("a production app got a trash marker anyway: %+v", tomb)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
+ t.Fatalf("HasAppIn = %v %v, want the row live", has, err)
+ }
+}
+
+// Deleting is the owner's, and the refusal is the byte-identical 404 — a
+// member must not learn that the door exists.
+func TestAppDeleteIsOwnerOnly(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("sam@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-sam-appdelete")
+ csrf := sessionToken(t, fx, c)
+
+ status, _, memberBody := postForm(t, c, fx.u("/apps/kass/delete"),
+ url.Values{"csrf": {csrf}, "confirm": {"kass"}})
+ if status != http.StatusNotFound {
+ t.Fatalf("member deleting an app = %d, want 404", status)
+ }
+ strangerBody := strangerRefusal(t, fx, "code-zoe-appdelete", fx.u("/apps/kass/delete"),
+ url.Values{"confirm": {"kass"}})
+ if memberBody != strangerBody {
+ t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody)
+ }
+ if tomb := appTombstone(t, fx, "kass"); tomb != nil {
+ t.Fatalf("a member's refused delete wrote a marker: %+v", tomb)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
+ t.Fatalf("HasAppIn = %v %v, want the row live", has, err)
+ }
+}
+
+// ---- handleAppMoveAccount ---------------------------------------------------
+
+// stampKassRoutes re-writes the fixture's kass routes with a stamped account,
+// which is what `carlos accounts migrate` leaves behind: the routes name the
+// new account, the console record still names the old one. That disagreement
+// IS the state this handler repairs — and the target comes from the routes,
+// never the form.
+func stampKassRoutes(t *testing.T, fx *consoleFixture, acct string) {
+ t.Helper()
+ fx.addRoute(t, registry.Route{Host: "kass.carlosapp.dev", Kind: "instance", Status: "ready",
+ Version: "v1", App: "kass", Channel: "stable", Account: acct, Addr: "127.0.0.1:9001"})
+ fx.addRoute(t, registry.Route{Host: "docs.kass.carlosapp.dev", Kind: "static", Status: "ready",
+ Version: "v1", App: "kass", Account: acct, Hibernate: true})
+}
+
+// The repair: card, passkey, and the record lands in the account the routes
+// already deploy from — with the arrival recorded on BOTH accounts, since
+// neither activity trail alone tells the whole story afterwards.
+func TestAppMoveConfirmsThenMovesTheRecord(t *testing.T) {
+ fx := newConsoleFixture(t)
+ const destID = 4242
+ if err := fx.st.CreateAccount(destID, "cdef", "Second", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ stampKassRoutes(t, fx, "cdef")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ form := url.Values{"csrf": {csrf}}
+
+ status, loc, body := postForm(t, c, fx.u("/apps/kass/move"), form)
+ if strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Fatalf("the owner met the passkey before the card (-> %q)", loc)
+ }
+ card := confirmCard(t, status, body, "Move kass to Second?", csrf)
+ if !strings.Contains(card, "Move to Second") {
+ t.Fatalf("the card's button does not name the destination:\n%s", card)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
+ t.Fatalf("HasAppIn(ops) = %v %v after the card, want the record unmoved", has, err)
+ }
+
+ form.Set("confirmed", "1")
+ status, loc, _ = postForm(t, c, fx.u("/apps/kass/move"), form)
+ if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Fatalf("confirmed move = %d -> %q, want a redirect into Keymail", status, loc)
+ }
+ if has, err := fx.st.HasAppIn(destID, "kass"); err != nil || has {
+ t.Fatalf("HasAppIn(dest) = %v %v before the passkey, want nothing moved", has, err)
+ }
+
+ status, loc, body = completeStepUp(t, fx, c, loc)
+ if status != http.StatusSeeOther || loc != "/a/cdef/apps/kass" {
+ t.Fatalf("resumed move = %d -> %q, want 303 to the app under its new account:\n%s", status, loc, body)
+ }
+ if has, err := fx.st.HasAppIn(destID, "kass"); err != nil || !has {
+ t.Fatalf("HasAppIn(dest) = %v %v, want the record in the destination account", has, err)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || has {
+ t.Fatalf("HasAppIn(ops) = %v %v, want the record gone from the source", has, err)
+ }
+ // Both trails: the source records the departure, the destination the arrival.
+ auditedBy(t, fx, "kass", "move", "paul@keymail.dev", "moved kass to account Second (cdef)")
+ arrived, err := fx.st.Events(destID, "kass", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var found bool
+ for _, e := range arrived {
+ if e.Action == "move" && strings.Contains(e.Detail, "arrived from account") {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("destination events = %+v, want the arrival recorded there too", arrived)
+ }
+}
+
+// Moving the record into an account the viewer does not belong to would strand
+// the app for them — refused, with the operator path named instead.
+func TestAppMoveRefusesANonMemberOfTheTarget(t *testing.T) {
+ fx := newConsoleFixture(t)
+ const destID = 4343
+ if err := fx.st.CreateAccount(destID, "cghi", "Third", "kim@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ stampKassRoutes(t, fx, "cghi")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/move"),
+ url.Values{"csrf": {csrf}, "confirmed": {"1"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("move into a stranger account = %d -> %q:\n%s", status, loc, body)
+ }
+ notACard(t, body)
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page,
+ "You are not a member of Third (cghi)") {
+ t.Fatalf("no non-membership refusal flash:\n%s", page)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
+ t.Fatalf("HasAppIn(ops) = %v %v, want the record where it was", has, err)
+ }
+ if has, err := fx.st.HasAppIn(destID, "kass"); err != nil || has {
+ t.Fatalf("HasAppIn(dest) = %v %v, want nothing moved", has, err)
+ }
+}
+
+// With no disagreement there is nothing to repair — and no card, because the
+// handler has already decided.
+func TestAppMoveRefusesWhenRoutesAgree(t *testing.T) {
+ fx := newConsoleFixture(t)
+ // The fixture's routes carry no stamped account, which reads as the ops
+ // account — the very account this page belongs to.
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, body := postForm(t, c, fx.u("/apps/kass/move"), url.Values{"csrf": {csrf}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("move with agreeing routes = %d -> %q:\n%s", status, loc, body)
+ }
+ notACard(t, body)
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page,
+ "there is nothing to move") {
+ t.Fatalf("no nothing-to-move flash:\n%s", page)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
+ t.Fatalf("HasAppIn = %v %v, want the record untouched", has, err)
+ }
+}
+
+// ---- the Settings page ------------------------------------------------------
+
+// The rename form is offered while the name is still only a record here, and
+// replaced by the reason the moment the bucket knows it.
+func TestSettingsPageRenameFormFollowsTheBucket(t *testing.T) {
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "sprout")
+ c := signInPaul(t, fx)
+
+ status, _, body := get(t, c, fx.u("/apps/sprout/settings"))
+ if status != http.StatusOK {
+ t.Fatalf("settings page = %d:\n%s", status, body)
+ }
+ if !strings.Contains(body, `id="rename-to"`) ||
+ !strings.Contains(body, `action="/a/bab/apps/sprout/rename"`) {
+ t.Fatalf("a never-shipped app is offered no rename form:\n%s", body)
+ }
+
+ shipSomething(t, fx, "sprout")
+ _, _, body = get(t, c, fx.u("/apps/sprout/settings"))
+ if strings.Contains(body, `id="rename-to"`) {
+ t.Fatalf("the rename form survived the app's first ship:\n%s", body)
+ }
+ if !strings.Contains(body, "Renaming: This app has shipped") {
+ t.Fatalf("the page does not say why renaming is off:\n%s", body)
+ }
+}
+
+// Everything on this page that changes the app's identity is the owner's. A
+// member sees the identity and the domains, and neither form.
+func TestSettingsPageHidesOwnerFormsFromMembers(t *testing.T) {
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "sprout")
+ // The owner's own view first, so the member-side absences below cannot
+ // pass vacuously: both forms ARE eligible to render in this exact state.
+ owner := signInPaul(t, fx)
+ _, _, obody := get(t, owner, fx.u("/apps/sprout/settings"))
+ if !strings.Contains(obody, `id="rename-to"`) || !strings.Contains(obody, `id="delete-confirm"`) {
+ t.Fatalf("the owner is not offered both forms — the member checks below would be vacuous:\n%s", obody)
+ }
+
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("sam@keymail.dev")
+ cm := newBrowser(t)
+ signUp(t, fx, cm, "code-sam-settings")
+ status, _, body := get(t, cm, fx.u("/apps/sprout/settings"))
+ if status != http.StatusOK {
+ t.Fatalf("member settings page = %d, want 200:\n%s", status, body)
+ }
+ if !strings.Contains(body, "Domains") {
+ t.Fatalf("a member cannot see the page at all:\n%s", body)
+ }
+ if strings.Contains(body, `id="rename-to"`) || strings.Contains(body, "/rename\"") {
+ t.Errorf("a member is offered the rename form:\n%s", body)
+ }
+ if strings.Contains(body, `id="delete-confirm"`) || strings.Contains(body, "/delete\"") {
+ t.Errorf("a member is offered the delete form:\n%s", body)
+ }
+}
diff --git a/internal/console/bakecontrols_test.go b/internal/console/bakecontrols_test.go
index d62dc69e0..7eaf8d748 100644
--- a/internal/console/bakecontrols_test.go
+++ b/internal/console/bakecontrols_test.go
@@ -168,7 +168,7 @@ func TestFastTrackIsOwnerPasskeyGatedAndLands(t *testing.T) {
// The fixture's stable already points at v1. Without the passkey the
// waiver does not happen.
status, loc, _ := postForm(t, c, fx.u("/apps/kass/fasttrack"),
- url.Values{"csrf": {csrf}, "channel": {"stable"}})
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "confirmed": {"1"}})
if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
t.Fatalf("un-stepped-up fast-track = %d -> %q, want a redirect into Keymail", status, loc)
}
@@ -204,7 +204,7 @@ func TestFastTrackRefusesAChannelWithNoBakeWindow(t *testing.T) {
csrf := sessionToken(t, fx, c)
status, _, _ := postGated(t, fx, c, fx.u("/apps/kass/fasttrack"),
- url.Values{"csrf": {csrf}, "channel": {"canary/x"}})
+ url.Values{"csrf": {csrf}, "channel": {"canary/x"}, "confirmed": {"1"}})
if status != http.StatusSeeOther {
t.Fatalf("fast-track of a bake-free channel = %d, want the flash redirect", status)
}
@@ -268,7 +268,7 @@ func TestPromoteEnableBakeFlipsTheFlag(t *testing.T) {
csrf := sessionToken(t, fx, c)
postGated(t, fx, c, fx.u("/apps/kass/promote"),
- url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}, "enable_bake": {"1"}})
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}, "enable_bake": {"1"}, "confirmed": {"1"}})
if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v3" {
t.Fatalf("stable = %s, want v3", p.Version)
}
@@ -298,7 +298,7 @@ func TestPromoteEnableBakeSaysWhenItCouldNotRead(t *testing.T) {
}
postGated(t, fx, c, fx.u("/apps/kass/promote"),
- url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}, "enable_bake": {"1"}})
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}, "enable_bake": {"1"}, "confirmed": {"1"}})
if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v3" {
t.Fatalf("stable = %s, want v3 — the promote itself must not depend on the flag", p.Version)
}
@@ -326,7 +326,7 @@ func TestPromoteBakeNoteFollowsTheFlag(t *testing.T) {
// Checked, not fired and forgotten: a refused promote would make the
// absence assertion below true for the wrong reason.
if status, loc, body := postForm(t, c, fx.u("/apps/kass/promote"),
- url.Values{"csrf": {csrf}, "version": {"v3"}, "channel": {"beta"}}); status != http.StatusSeeOther ||
+ url.Values{"csrf": {csrf}, "version": {"v3"}, "channel": {"beta"}, "confirmed": {"1"}}); status != http.StatusSeeOther ||
loc != fx.path("/apps/kass") {
t.Fatalf("beta promote = %d -> %q:\n%s", status, loc, body)
}
@@ -345,7 +345,7 @@ func TestPromoteBakeNoteFollowsTheFlag(t *testing.T) {
shipVersion(t, fx, "kass", "v4")
seedChannel(t, fx, "kass", "edge", "v4")
if status, _, body := postForm(t, c, fx.u("/apps/kass/promote"),
- url.Values{"csrf": {csrf}, "version": {"v4"}, "channel": {"beta"}}); status != http.StatusSeeOther {
+ url.Values{"csrf": {csrf}, "version": {"v4"}, "channel": {"beta"}, "confirmed": {"1"}}); status != http.StatusSeeOther {
t.Fatalf("beta promote of v4 = %d:\n%s", status, body)
}
if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Adopts after the bake window") {
diff --git a/internal/console/confirm_test.go b/internal/console/confirm_test.go
new file mode 100644
index 000000000..e14ec9e6f
--- /dev/null
+++ b/internal/console/confirm_test.go
@@ -0,0 +1,314 @@
+package console
+
+// The action confirm interstitial (confirm.go). The properties under test are
+// the ones the file's own doc comment calls load-bearing: the card describes a
+// request that would otherwise proceed, it never renders for someone the owner
+// 404 is meant to turn away, and it sits strictly BETWEEN that 404 and the
+// passkey — so an owner sees the card first and is asked for the passkey only
+// on the confirmed replay.
+//
+// Every card test also lands the confirmed POST. A card that renders but whose
+// resubmit does not carry the action through is a dead end, and asserting only
+// on the markup would not notice.
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// confirmCard asserts the response IS a confirm card — 200, the titled
+// heading, the confirmed=1 marker the resubmit turns on, the CSRF token
+// carried through, and a submit button — and hands the body back for the
+// card-specific assertions. Everything here is what makes the card a working
+// resubmit rather than a screenshot of one.
+func confirmCard(t *testing.T, status int, body, title, csrf string) string {
+ t.Helper()
+ if status != http.StatusOK {
+ t.Fatalf("confirm step = %d, want a 200 card:\n%s", status, body)
+ }
+ if !strings.Contains(body, title) {
+ t.Fatalf("card is not titled %q:\n%s", title, body)
+ }
+ if !strings.Contains(body, `<input type="hidden" name="confirmed" value="1">`) {
+ t.Fatalf("card carries no confirmed=1 marker, so its button cannot land the action:\n%s", body)
+ }
+ if !strings.Contains(body, `<input type="hidden" name="csrf" value="`+csrf+`">`) {
+ t.Fatalf("card carries no csrf field, so its resubmit would be refused:\n%s", body)
+ }
+ if !strings.Contains(body, `<button type="submit"`) {
+ t.Fatalf("card has no submit button:\n%s", body)
+ }
+ return body
+}
+
+// notACard is the negative: whatever this response is, it must not be an
+// interstitial. Used wherever a card would concede that an action exists for
+// somebody (the owner-only 404s) or that it would otherwise proceed (the
+// cheap refusals).
+func notACard(t *testing.T, body string) {
+ t.Helper()
+ if strings.Contains(body, `name="confirmed"`) || strings.Contains(body, "confirm-card") {
+ t.Fatalf("a confirm card was rendered where none may be:\n%s", body)
+ }
+}
+
+// The ordinary promote: the unconfirmed POST is answered with the card and
+// changes nothing; the same POST plus confirmed=1 moves the channel.
+func TestConfirmPromoteCardThenLands(t *testing.T) {
+ fx := newConsoleFixture(t)
+ shipVersion(t, fx, "kass", "v3")
+ seedChannel(t, fx, "kass", "edge", "v3") // beta is entered from edge
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ form := url.Values{"csrf": {csrf}, "channel": {"beta"}, "version": {"v3"}}
+
+ status, _, body := postForm(t, c, fx.u("/apps/kass/promote"), form)
+ card := confirmCard(t, status, body, "Promote v3 to beta?", csrf)
+ // The card carries what the resubmit needs to be the SAME request.
+ for _, want := range []string{
+ `<input type="hidden" name="channel" value="beta">`,
+ `<input type="hidden" name="version" value="v3">`,
+ } {
+ if !strings.Contains(card, want) {
+ t.Fatalf("card does not carry %s:\n%s", want, card)
+ }
+ }
+ if !strings.Contains(card, "Promote v3") {
+ t.Fatalf("the card's button does not name the promotion:\n%s", card)
+ }
+ // A card is a question, not a deferral: beta does not exist yet.
+ if _, err := release.Channel(context.Background(), fx.rel, fx.pub,
+ release.OpsAccount, "kass", "beta"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("beta = %v after an UNconfirmed promote, want it still absent", err)
+ }
+
+ form.Set("confirmed", "1")
+ status, loc, body := postForm(t, c, fx.u("/apps/kass/promote"), form)
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass") {
+ t.Fatalf("confirmed promote = %d -> %q, want 303 to the app page:\n%s", status, loc, body)
+ }
+ if p := fx.channelVersion(t, "kass", "beta"); p.Version != "v3" {
+ t.Fatalf("beta = %s after a confirmed promote, want v3", p.Version)
+ }
+ if _, _, page := get(t, c, fx.u("/apps/kass")); !strings.Contains(page, "Promoted v3 to beta.") {
+ t.Fatalf("no promote flash:\n%s", page)
+ }
+}
+
+// The stable-target card is the one with a real choice on it: bake is off for
+// this app, so the card offers to turn it on. And the card is strictly for
+// owners — a member probing the stable rung meets the byte-identical 404 with
+// no card in it, because a card would concede the action exists for somebody.
+func TestConfirmStablePromoteOffersBakeAndHidesFromMembers(t *testing.T) {
+ fx := newConsoleFixture(t)
+ shipVersion(t, fx, "kass", "v3")
+ seedChannel(t, fx, "kass", "beta", "v3") // stable is entered from beta
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, body := postForm(t, c, fx.u("/apps/kass/promote"),
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}})
+ // The card comes BEFORE the passkey: an owner is asked "do you mean it?"
+ // first, and only the confirmed replay meets requireFreshAuth.
+ if strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Fatalf("the owner was sent to Keymail before the card was shown (-> %q)", loc)
+ }
+ card := confirmCard(t, status, body, "Promote v3 to stable?", csrf)
+ if !strings.Contains(card, `name="enable_bake"`) {
+ t.Fatalf("the stable card carries no enable_bake choice for a bake-off app:\n%s", card)
+ }
+ if !strings.Contains(card, "puts v3 in front of everyone") {
+ t.Fatalf("the stable card does not warn what stable means:\n%s", card)
+ }
+ if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v1" {
+ t.Fatalf("stable moved to %s on an unconfirmed post", p.Version)
+ }
+
+ // A member: the byte-identical 404, and not a card in sight.
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("sam@keymail.dev")
+ cm := newBrowser(t)
+ signUp(t, fx, cm, "code-sam-confirm-stable")
+ mcsrf := sessionToken(t, fx, cm)
+ status, _, memberBody := postForm(t, cm, fx.u("/apps/kass/promote"),
+ url.Values{"csrf": {mcsrf}, "channel": {"stable"}, "version": {"v3"}})
+ if status != http.StatusNotFound {
+ t.Fatalf("member promoting to stable = %d, want 404", status)
+ }
+ notACard(t, memberBody)
+ strangerBody := strangerRefusal(t, fx, "code-zoe-confirm-stable", fx.u("/apps/kass/promote"),
+ url.Values{"channel": {"stable"}, "version": {"v3"}})
+ if memberBody != strangerBody {
+ t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody)
+ }
+ if memberBody != "404 page not found\n" {
+ t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", memberBody)
+ }
+}
+
+// The rollback card names BOTH versions — a rollback card that cannot say
+// where it is going has no business asking anyone to confirm it — and the
+// confirmed post re-points the channel.
+func TestConfirmRollbackCardShowsBothVersions(t *testing.T) {
+ fx := newConsoleFixture(t)
+ // stable: v1 (fixture) then v2, so the pointer has a history to go back to.
+ seedChannel(t, fx, "kass", "stable", "v2")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ form := url.Values{"csrf": {csrf}, "channel": {"stable"}}
+
+ status, _, body := postForm(t, c, fx.u("/apps/kass/rollback"), form)
+ card := confirmCard(t, status, body, "Roll back stable to v1?", csrf)
+ if !strings.Contains(card, "stable serves v2 now.") {
+ t.Fatalf("the card does not say where the channel is now:\n%s", card)
+ }
+ if !strings.Contains(card, "Roll back to v1") {
+ t.Fatalf("the card's button does not name the target version:\n%s", card)
+ }
+ if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v2" {
+ t.Fatalf("stable = %s on an unconfirmed rollback, want it untouched at v2", p.Version)
+ }
+
+ form.Set("confirmed", "1")
+ if status, _, body := postForm(t, c, fx.u("/apps/kass/rollback"), form); status != http.StatusSeeOther {
+ t.Fatalf("confirmed rollback = %d:\n%s", status, body)
+ }
+ if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v1" {
+ t.Fatalf("stable = %s after a confirmed rollback, want v1", p.Version)
+ }
+ if _, _, page := get(t, c, fx.u("/apps/kass")); !strings.Contains(page, "Rolled back stable to v1.") {
+ t.Fatalf("no rollback flash:\n%s", page)
+ }
+}
+
+// Fast-track is the whole gate order in one handler: member → 404 with no
+// card; owner → card BEFORE any Keymail round trip; confirmed owner → the
+// passkey, and only then the waiver.
+func TestConfirmFastTrackCardComesBeforeStepUp(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("sam@keymail.dev")
+ cm := newBrowser(t)
+ signUp(t, fx, cm, "code-sam-confirm-ft")
+ mcsrf := sessionToken(t, fx, cm)
+ status, _, memberBody := postForm(t, cm, fx.u("/apps/kass/fasttrack"),
+ url.Values{"csrf": {mcsrf}, "channel": {"stable"}})
+ if status != http.StatusNotFound {
+ t.Fatalf("member fast-tracking = %d, want 404", status)
+ }
+ notACard(t, memberBody)
+ if memberBody != "404 page not found\n" {
+ t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", memberBody)
+ }
+
+ fx.setUser("paul@keymail.dev")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ form := url.Values{"csrf": {csrf}, "channel": {"stable"}}
+
+ status, loc, body := postForm(t, c, fx.u("/apps/kass/fasttrack"), form)
+ if strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Fatalf("the owner met the passkey before the card (-> %q) — confirm asks \"do you mean it?\" first", loc)
+ }
+ card := confirmCard(t, status, body, "Fast-track v1 on stable?", csrf)
+ if !strings.Contains(card, "The bake window is the supply-chain guard") {
+ t.Fatalf("the fast-track card carries no warning:\n%s", card)
+ }
+ if p := fx.channelVersion(t, "kass", "stable"); p.FastTrack {
+ t.Fatal("the window was waived by merely asking")
+ }
+
+ // The confirmed replay is where the passkey is demanded — and where it lands.
+ form.Set("confirmed", "1")
+ status, loc, _ = postForm(t, c, fx.u("/apps/kass/fasttrack"), form)
+ if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Fatalf("confirmed fast-track = %d -> %q, want a redirect into Keymail", status, loc)
+ }
+ if p := fx.channelVersion(t, "kass", "stable"); p.FastTrack {
+ t.Fatal("the window was waived with no passkey confirmation")
+ }
+ if status, _, body := completeStepUp(t, fx, c, loc); status != http.StatusSeeOther {
+ t.Fatalf("resumed fast-track = %d:\n%s", status, body)
+ }
+ if p := fx.channelVersion(t, "kass", "stable"); !p.FastTrack {
+ t.Fatalf("pointer = %+v after the confirmed, stepped-up waiver, want fast_track set", p)
+ }
+}
+
+// The retire card states exactly the two facts the handler established before
+// asking — the canary exists, and nothing follows it — so neither a canary
+// with a host on it nor a name that was never a canary gets one. The confirmed
+// post deletes the pointer.
+func TestConfirmRetireCanaryCardAndDeletion(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ // No such canary: a refusal, never a card describing a deletion that
+ // could not happen.
+ status, _, body := postForm(t, c, fx.u("/apps/kass/canary/retire"),
+ url.Values{"csrf": {csrf}, "name": {"nope"}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("retire of a missing canary = %d, want the flash redirect:\n%s", status, body)
+ }
+ notACard(t, body)
+
+ // The fixture's canary/x exists and nothing follows it: the card.
+ form := url.Values{"csrf": {csrf}, "name": {"x"}}
+ status, _, body = postForm(t, c, fx.u("/apps/kass/canary/retire"), form)
+ card := confirmCard(t, status, body, "Retire canary x?", csrf)
+ for _, want := range []string{
+ "Deletes channel canary/x",
+ "No host follows it",
+ `<input type="hidden" name="name" value="x">`,
+ } {
+ if !strings.Contains(card, want) {
+ t.Fatalf("the retire card is missing %q:\n%s", want, card)
+ }
+ }
+ if p := fx.channelVersion(t, "kass", "canary/x"); p.Version != "v2" {
+ t.Fatalf("canary/x = %+v after an unconfirmed retire, want it untouched", p)
+ }
+
+ form.Set("confirmed", "1")
+ if status, _, body := postForm(t, c, fx.u("/apps/kass/canary/retire"), form); status != http.StatusSeeOther {
+ t.Fatalf("confirmed retire = %d:\n%s", status, body)
+ }
+ if _, err := release.Channel(context.Background(), fx.rel, fx.pub,
+ release.OpsAccount, "kass", "canary/x"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("canary/x = %v after a confirmed retire, want it gone", err)
+ }
+
+ // A canary with a follower never reaches the card at all — the refusal
+ // fires first, and a card here would be describing a deletion the handler
+ // has already decided against.
+ seedChannel(t, fx, "kass", "canary/y", "v2")
+ fx.addRoute(t, registry.Route{Host: "rc.kass.carlosapp.dev", Kind: "instance", Status: "ready",
+ Version: "v2", App: "kass", Channel: "canary/y", Addr: "127.0.0.1:9021"})
+ status, _, body = postForm(t, c, fx.u("/apps/kass/canary/retire"),
+ url.Values{"csrf": {csrf}, "name": {"y"}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("retire of a followed canary = %d, want the flash redirect:\n%s", status, body)
+ }
+ notACard(t, body)
+ if p := fx.channelVersion(t, "kass", "canary/y"); p.Version != "v2" {
+ t.Fatalf("canary/y = %+v, want the followed canary untouched", p)
+ }
+ if _, _, page := get(t, c, fx.u("/apps/kass")); !strings.Contains(page,
+ "still has a host (rc.kass.carlosapp.dev) following it") {
+ t.Fatalf("no follower refusal flash:\n%s", page)
+ }
+}
diff --git a/internal/console/ladder_test.go b/internal/console/ladder_test.go
index d94de51ba..818be79a8 100644
--- a/internal/console/ladder_test.go
+++ b/internal/console/ladder_test.go
@@ -90,7 +90,7 @@ func TestLadderRendersFourRungs(t *testing.T) {
release.Promotion{Version: "v4", From: "edge", To: "beta", Actor: "kim@keymail.dev"})
c := signInPaul(t, fx)
- status, _, body := get(t, c, fx.u("/apps/kass"))
+ status, _, body := get(t, c, fx.u("/apps/kass/releases"))
if status != http.StatusOK {
t.Fatalf("app page = %d", status)
}
@@ -110,9 +110,12 @@ func TestLadderRendersFourRungs(t *testing.T) {
if b := rungBlock(t, body, "edge"); !strings.Contains(b, "ci@keymail.dev") {
t.Errorf("the edge rung does not say who promoted it:\n%s", b)
}
- // The pre-audit pointer: rendered, and honest about what it cannot say.
- if b := rungBlock(t, body, "stable"); !strings.Contains(b, "not recorded") {
- t.Errorf("a pointer with no audit trail did not say so:\n%s", b)
+ // The pre-audit pointer: rendered, and honest about what it cannot say —
+ // the template now omits the "by ..." clause entirely rather than
+ // attributing the promotion to anybody, so the dishonest alternative
+ // (inventing an actor) is what must not appear.
+ if b := rungBlock(t, body, "stable"); strings.Contains(b, "ago by") {
+ t.Errorf("a pointer with no audit trail invented an actor:\n%s", b)
}
// Routes sit under the rung they subscribe to.
if b := rungBlock(t, body, "stable"); !strings.Contains(b, "kass.carlosapp.dev") {
@@ -128,7 +131,7 @@ func TestLadderPromoteControlIsOwnerOnly(t *testing.T) {
seedChannel(t, fx, "kass", "edge", "v3")
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
if !strings.Contains(body, "Promote v3 to beta") {
t.Fatalf("owner is offered no edge -> beta control:\n%s", body)
}
@@ -144,7 +147,7 @@ func TestLadderPromoteControlIsOwnerOnly(t *testing.T) {
fx.setUser("sam@keymail.dev")
cm := newBrowser(t)
signUp(t, fx, cm, "code-sam-ladder")
- status, _, mbody := get(t, cm, fx.u("/apps/kass"))
+ status, _, mbody := get(t, cm, fx.u("/apps/kass/releases"))
if status != http.StatusOK {
t.Fatalf("member app page = %d, want 200", status)
}
@@ -167,7 +170,7 @@ func TestLadderPromoteToStableNeedsStepUp(t *testing.T) {
csrf := sessionToken(t, fx, c)
status, loc, _ := postForm(t, c, fx.u("/apps/kass/promote"),
- url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}})
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}, "confirmed": {"1"}})
if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
t.Fatalf("un-stepped-up stable promotion = %d -> %q, want a redirect into Keymail", status, loc)
}
@@ -204,7 +207,7 @@ func TestLadderRefusesASkip(t *testing.T) {
// postGated walks the passkey round trip, so this is the refusal of a
// FULLY confirmed owner — the ladder, not the gate, is what stops it.
postGated(t, fx, c, fx.u("/apps/kass/promote"),
- url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}})
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}, "confirmed": {"1"}})
if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v1" {
t.Fatalf("a skip from edge moved stable to %s", p.Version)
}
@@ -230,7 +233,7 @@ func TestProdWiredCanaryIsBadgedDistinctly(t *testing.T) {
fx.addRoute(t, registry.Route{Host: "side.kass.carlosapp.dev", Kind: "instance", Status: "ready",
Version: "v2", App: "kass", Channel: "canary/x", Environment: "sandbox", Addr: "127.0.0.1:9003"})
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
block := rungBlock(t, body, "canary")
sameEnv := routeLine(t, block, "rc.kass.carlosapp.dev")
@@ -270,7 +273,7 @@ func TestProdWiredCanaryOnTheApexHostIsFlaggedWhateverItBinds(t *testing.T) {
Version: "v2", App: "kass", Channel: "canary/x", Environment: "sandbox", Addr: "127.0.0.1:9007"})
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
block := rungBlock(t, body, "canary")
line := routeLine(t, block, "kass.carlosapp.dev")
if !strings.Contains(line, "chip-prod") {
@@ -304,7 +307,7 @@ func TestProdWiredCanaryNeedsAStableRouteToCompareAgainst(t *testing.T) {
Version: "v2", App: "kass", Channel: "canary/x", Addr: "127.0.0.1:9004"})
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
block := rungBlock(t, body, "canary")
line := routeLine(t, block, "rc.kass.carlosapp.dev")
if strings.Contains(line, "chip-prod") {
@@ -321,7 +324,7 @@ func TestLadderShowsEachRoutesEnvironment(t *testing.T) {
Version: "v1", App: "kass", Channel: "stable", Environment: "eu-prod", Addr: "127.0.0.1:9011"})
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
block := rungBlock(t, body, "stable")
unbound := routeLine(t, block, "kass.carlosapp.dev")
@@ -347,7 +350,7 @@ func TestLadderShowsGrantedCapabilities(t *testing.T) {
Version: "v1", App: "kass", Channel: "stable", Caps: "host-role,port:25", Addr: "127.0.0.1:9013"})
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
block := rungBlock(t, body, "stable")
granted := routeLine(t, block, "mx.kass.carlosapp.dev")
@@ -392,7 +395,7 @@ func TestLadderGrantedCapabilitiesNeverCrossAccounts(t *testing.T) {
Caps: "host-role,port:587", Addr: "127.0.0.1:9015"})
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
block := rungBlock(t, body, "stable")
own := routeLine(t, block, "mx.kass.carlosapp.dev")
@@ -430,7 +433,7 @@ func TestLadderEnvironmentAndProdWiredVisibleToNonOwner(t *testing.T) {
fx.setUser("sam@keymail.dev")
cm := newBrowser(t)
signUp(t, fx, cm, "code-sam-envs")
- status, _, body := get(t, cm, fx.u("/apps/kass"))
+ status, _, body := get(t, cm, fx.u("/apps/kass/releases"))
if status != http.StatusOK {
t.Fatalf("member app page = %d, want 200", status)
}
@@ -459,7 +462,7 @@ func TestLadderFreshCanaryCarriesNoExpiredBadge(t *testing.T) {
seedChannel(t, fx, "kass", "canary/x", "v2") // stamps PromotedAt = now
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
block := rungBlock(t, body, "canary")
line := routeLine(t, block, "rc.kass.carlosapp.dev")
@@ -611,7 +614,7 @@ func TestLadderPromotesOntoAnExpiredCanary(t *testing.T) {
csrf := sessionToken(t, fx, c)
status, _, body := postForm(t, c, fx.u("/apps/kass/promote"),
- url.Values{"csrf": {csrf}, "channel": {"canary/old-branch"}, "version": {"v8"}})
+ url.Values{"csrf": {csrf}, "channel": {"canary/old-branch"}, "version": {"v8"}, "confirmed": {"1"}})
if status != http.StatusSeeOther {
t.Fatalf("promotion onto an expired canary = %d, want 303:\n%s", status, body)
}
@@ -630,16 +633,21 @@ func TestLadderShowsBakeStateAndControls(t *testing.T) {
seedChannel(t, fx, "kass", "beta", "v3")
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ csrf := sessionToken(t, fx, c)
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
if !strings.Contains(body, "Supply-chain bake") {
t.Fatalf("no bake status line:\n%s", body)
}
if !strings.Contains(body, `action="/a/bab/apps/kass/production"`) {
t.Fatal("owner is offered no production toggle")
}
- // Off by default, and the stable promote form asks the question.
- if !strings.Contains(body, `name="enable_bake"`) {
- t.Fatal("the stable promote form does not carry the enable_bake prompt")
+ // Off by default, and the stable promote form asks the question — now on
+ // the confirm card the un-confirmed promote returns, not the ladder
+ // itself (the checkbox moved there; see confirm.go's confirmPromote).
+ _, _, confirmBody := postForm(t, c, fx.u("/apps/kass/promote"),
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}})
+ if !strings.Contains(confirmBody, `name="enable_bake"`) {
+ t.Fatal("the stable promote confirm card does not carry the enable_bake prompt")
}
// Flip on; the held beta head now shows its window and the fast-track
@@ -648,14 +656,18 @@ func TestLadderShowsBakeStateAndControls(t *testing.T) {
release.OpsAccount, "kass", true, "test"); err != nil {
t.Fatal(err)
}
- _, _, body = get(t, c, fx.u("/apps/kass"))
+ _, _, body = get(t, c, fx.u("/apps/kass/releases"))
if !strings.Contains(body, `action="/a/bab/apps/kass/fasttrack"`) {
t.Fatalf("no fast-track control on a held head:\n%s", rungBlock(t, body, "beta"))
}
if !strings.Contains(body, `name="production" value="off"`) {
t.Fatal("owner is offered no turn-off control once bake is on")
}
- if strings.Contains(body, `name="enable_bake"`) {
+ // The confirm card's checkbox no longer offers to enable the bake once
+ // it's already on.
+ _, _, confirmBody = postForm(t, c, fx.u("/apps/kass/promote"),
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}})
+ if strings.Contains(confirmBody, `name="enable_bake"`) {
t.Fatal("the enable_bake prompt survived the flag turning on")
}
@@ -664,7 +676,7 @@ func TestLadderShowsBakeStateAndControls(t *testing.T) {
release.OpsAccount, "kass", "beta", "test"); err != nil {
t.Fatal(err)
}
- _, _, body = get(t, c, fx.u("/apps/kass"))
+ _, _, body = get(t, c, fx.u("/apps/kass/releases"))
b := rungBlock(t, body, "beta")
if !strings.Contains(b, "fast-tracked") {
t.Errorf("no permanent fast-tracked badge:\n%s", b)
@@ -696,7 +708,7 @@ func TestLadderBakeControlsMemberView(t *testing.T) {
cm := newBrowser(t)
signUp(t, fx, cm, "code-sam-baketpl")
- _, _, mbody := get(t, cm, fx.u("/apps/kass"))
+ _, _, mbody := get(t, cm, fx.u("/apps/kass/releases"))
if !strings.Contains(mbody, "Supply-chain bake") {
t.Fatal("member sees no bake status")
}
@@ -717,7 +729,7 @@ func TestLadderBakeControlsMemberView(t *testing.T) {
release.OpsAccount, "kass", true, "test"); err != nil {
t.Fatal(err)
}
- _, _, obody := get(t, c, fx.u("/apps/kass"))
+ _, _, obody := get(t, c, fx.u("/apps/kass/releases"))
if !strings.Contains(obody, `name="production" value="off"`) {
t.Fatal("owner is not offered the turn-off control once bake is eligible — the member-only checks below would be vacuous")
}
@@ -726,7 +738,7 @@ func TestLadderBakeControlsMemberView(t *testing.T) {
}
// ...then confirm the member does NOT, in that same eligible state.
- _, _, mbody = get(t, cm, fx.u("/apps/kass"))
+ _, _, mbody = get(t, cm, fx.u("/apps/kass/releases"))
if strings.Contains(mbody, `name="production" value="off"`) {
t.Error("member is offered the owner-only turn-off control once bake is on")
}
@@ -747,8 +759,8 @@ func TestLadderUnreadableFlagOffersRepair(t *testing.T) {
release.ProductionKey(release.OpsAccount, "kass"), []byte("{not a wrapper"), ""); err != nil {
t.Fatal(err)
}
- _, _, body := get(t, c, fx.u("/apps/kass"))
- if !strings.Contains(body, "Production flag unreadable") {
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
+ if !strings.Contains(body, "Supply-chain bake: flag unreadable") {
t.Fatalf("no error line for an unverifiable flag:\n%s", body)
}
if !strings.Contains(body, `action="/a/bab/apps/kass/production"`) ||
@@ -791,7 +803,7 @@ func TestLadderTurningOffOffersTurnBackOn(t *testing.T) {
if err := release.SetProduction(ctx, fx.rel, fx.priv, release.OpsAccount, "kass", false, "test"); err != nil {
t.Fatal(err)
}
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
if !strings.Contains(body, "turning off") {
t.Fatalf("a fresh off-flag on a held app does not say hosts are still ratcheting:\n%s", body)
}
@@ -813,7 +825,7 @@ func TestLadderNudgesProdWiredBakeOff(t *testing.T) {
c := signInPaul(t, fx)
// The fixture's kass.carlosapp.dev is on stable and the flag is off (the
// default): that IS the nudge condition.
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
if !strings.Contains(body, "adopts releases immediately") {
t.Fatalf("no prod-wiring nudge:\n%s", body)
}
@@ -821,7 +833,7 @@ func TestLadderNudgesProdWiredBakeOff(t *testing.T) {
release.OpsAccount, "kass", true, "test"); err != nil {
t.Fatal(err)
}
- if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "adopts releases immediately") {
+ if _, _, body := get(t, c, fx.u("/apps/kass/releases")); strings.Contains(body, "adopts releases immediately") {
t.Fatal("the nudge survived the flag turning on")
}
}
diff --git a/internal/console/metricsview_test.go b/internal/console/metricsview_test.go
index d54d4213c..380b74eba 100644
--- a/internal/console/metricsview_test.go
+++ b/internal/console/metricsview_test.go
@@ -3,6 +3,7 @@ package console
import (
"context"
"encoding/json"
+ "errors"
"path/filepath"
"strconv"
"strings"
@@ -302,6 +303,167 @@ func TestLoadMetricsConcurrentColdCallers(t *testing.T) {
}
}
+// errOnKeyStore fails one specific Get with a non-NotExist error and passes
+// everything else through — the "bucket is answering, but this object will
+// not read" case, which must fail the whole sweep rather than being skipped
+// like a missing hour.
+type errOnKeyStore struct {
+ release.Store
+ key string
+ err error
+}
+
+func (e errOnKeyStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key == e.key {
+ return nil, "", e.err
+ }
+ return e.Store.Get(ctx, key)
+}
+
+// newMetricsFixtureStore is newMetricsFixture with the server reading through
+// a wrapped store. The returned release.Store is the raw one, for seeding.
+func newMetricsFixtureStore(t *testing.T, wrap func(release.Store) release.Store) (*Server, release.Store) {
+ 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: wrap(rel)}, st)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return srv, rel
+}
+
+// writeChunk publishes one hour's raw chunk for box, the way the box agent
+// would.
+func writeChunk(t *testing.T, rel release.Store, box, hour string, samples []metrics.Sample) {
+ t.Helper()
+ if err := metrics.WriteRaw(context.Background(), rel, metrics.RawChunk{
+ V: 1, Box: box, Hour: hour, Samples: samples,
+ }); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// seedTiedHours writes three hours for box-a whose samples deliberately SHARE
+// a timestamp across different hours, plus a second box. Ties are the whole
+// point: sort.Slice is not stable, so which tied sample ends up last — and so
+// which reading the box's row prints as current — is decided by the order the
+// hours were concatenated in, not by the sort. Returns the shared timestamp.
+func seedTiedHours(t *testing.T, rel release.Store) time.Time {
+ t.Helper()
+ now := time.Now().UTC()
+ tie := now.Truncate(time.Second)
+ unit := func(cpu float64, mem int64) map[string]metrics.UnitSample {
+ return map[string]metrics.UnitSample{"carlos-app@a.service": {CPUFrac: p(cpu), MemBytes: pi64(mem)}}
+ }
+ writeChunk(t, rel, "box-a", metrics.HourOf(now.Add(-2*time.Hour)), []metrics.Sample{
+ {At: tie.Add(-2 * time.Hour), Host: metrics.HostSample{CPUFrac: p(0.10)}, Units: unit(0.11, 110)},
+ {At: tie, Host: metrics.HostSample{CPUFrac: p(0.20)}, Units: unit(0.22, 220)},
+ })
+ writeChunk(t, rel, "box-a", metrics.HourOf(now.Add(-time.Hour)), []metrics.Sample{
+ {At: tie.Add(-time.Hour), Host: metrics.HostSample{CPUFrac: p(0.30)}, Units: unit(0.33, 330)},
+ {At: tie, Host: metrics.HostSample{CPUFrac: p(0.40)}, Units: unit(0.44, 440)},
+ })
+ writeChunk(t, rel, "box-a", metrics.HourOf(now), []metrics.Sample{
+ {At: tie, Host: metrics.HostSample{CPUFrac: p(0.50)}, Units: unit(0.55, 550)},
+ })
+ writeChunk(t, rel, "box-b", metrics.HourOf(now), []metrics.Sample{
+ {At: tie, Host: metrics.HostSample{CPUFrac: p(0.60)}},
+ })
+ return tie
+}
+
+// TestSweepAllDeterministicOrder pins the sweep's output shape against the
+// timing of whatever reads it in: boxes come back in sorted name order, and a
+// box's samples in hour order (newest hour first, as the loop counts back),
+// so tied timestamps resolve the same way on every render. The pinned values
+// are the serial sweep's own answers — a sweep that assembled hours or boxes
+// in completion order would land on a different reading and the row would
+// flap between page loads.
+func TestSweepAllDeterministicOrder(t *testing.T) {
+ srv, rel := newMetricsFixture(t)
+ tie := seedTiedHours(t, rel)
+
+ views, _, err := srv.sweepAll(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(views) != 2 || views[0].Box != "box-a" || views[1].Box != "box-b" {
+ t.Fatalf("boxes = %+v, want box-a then box-b (sorted)", views)
+ }
+ a := views[0]
+ if !a.At.Equal(tie) {
+ t.Fatalf("At = %v, want the tied timestamp %v", a.At, tie)
+ }
+ // Hour order decides the tie: the LAST tied sample appended is the oldest
+ // hour's (0.20), not the current hour's (0.50).
+ if a.CPU == nil || *a.CPU != 0.20 {
+ t.Fatalf("CPU = %v, want 0.20 (the tie broken in hour order)", a.CPU)
+ }
+ if len(a.Units) != 1 || a.Units[0].CPUFrac == nil || *a.Units[0].CPUFrac != 0.22 {
+ t.Fatalf("units = %+v, want the same sample's unit reading 0.22", a.Units)
+ }
+ // The sparkline coordinate string encodes the whole ordered series, so it
+ // is the sharpest oracle for "the samples arrived in the same order".
+ const wantSpark = "0.00,36.00 60.00,28.00 120.00,20.00 180.00,24.00 240.00,32.00"
+ if a.SparkCPU != wantSpark {
+ t.Fatalf("SparkCPU = %q, want %q (samples in hour order)", a.SparkCPU, wantSpark)
+ }
+
+ // Same seed, repeated sweeps: identical every time, not merely plausible
+ // once.
+ for i := 0; i < 20; i++ {
+ got, _, err := srv.sweepAll(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 2 || got[0].Box != "box-a" || got[1].Box != "box-b" {
+ t.Fatalf("sweep %d box order = %+v", i, got)
+ }
+ if got[0].CPU == nil || *got[0].CPU != *a.CPU || got[0].SparkCPU != a.SparkCPU {
+ t.Fatalf("sweep %d differs: CPU=%v spark=%q, want %v / %q", i, got[0].CPU, got[0].SparkCPU, *a.CPU, a.SparkCPU)
+ }
+ if got[0].Units[0].SparkCPU != a.Units[0].SparkCPU || got[0].Units[0].SparkMem != a.Units[0].SparkMem {
+ t.Fatalf("sweep %d unit sparklines differ: %+v", i, got[0].Units[0])
+ }
+ }
+}
+
+// TestSweepAllFailsOnUnreadableChunk: one hour of one box answering with
+// something other than os.ErrNotExist fails the WHOLE sweep — no partial view
+// is returned and nothing is cached, so the next caller retries rather than
+// inheriting a box's truncated history as fact.
+func TestSweepAllFailsOnUnreadableChunk(t *testing.T) {
+ boom := errors.New("bucket said no")
+ badKey := metrics.RawKey("box-a", metrics.HourOf(time.Now().UTC().Add(-time.Hour)))
+ srv, rel := newMetricsFixtureStore(t, func(s release.Store) release.Store {
+ return errOnKeyStore{Store: s, key: badKey, err: boom}
+ })
+ seedTiedHours(t, rel)
+
+ if _, _, err := srv.LoadMetrics(context.Background()); err == nil {
+ t.Fatal("LoadMetrics succeeded despite an unreadable chunk")
+ } else {
+ if !errors.Is(err, boom) {
+ t.Fatalf("error = %v, want it to wrap the store's error", err)
+ }
+ if !strings.Contains(err.Error(), "read "+badKey) {
+ t.Fatalf("error = %v, want it to name the key it failed to read", err)
+ }
+ }
+ // A failed sweep is never cached.
+ srv.metricsCache.mu.Lock()
+ cachedAt := srv.metricsCache.at
+ srv.metricsCache.mu.Unlock()
+ if !cachedAt.IsZero() {
+ t.Fatalf("a failed sweep was cached (at = %v)", cachedAt)
+ }
+}
+
// TestOwnerAppMetricsEmptyHosts: an app with no routes asks for nothing and
// gets nothing — empty, no error, and (proven by the poisoned-free fixture
// having no boxes at all) never an error path the app page has to handle.
diff --git a/internal/console/overview_test.go b/internal/console/overview_test.go
new file mode 100644
index 000000000..ae22b241a
--- /dev/null
+++ b/internal/console/overview_test.go
@@ -0,0 +1,279 @@
+package console
+
+// The Overview dashboard's numbers and the Instances tab's one table
+// (overview.go). Both assemblers are pure joins over what the app pages
+// already read, so they are exercised directly here — the page tests below
+// then prove the numbers actually reach a rendered page.
+//
+// The properties worth pinning are the ones where a plausible-looking number
+// would be a lie: an unreadable stable pointer is UNKNOWN and not "not live",
+// a sum over instances that reported nothing is nil and not zero, a domain
+// mid-issuance needs patience and not attention, and a capped release list
+// says so.
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+)
+
+// f64 and i64 are the pointer literals InstanceMetrics carries — nil is a
+// meaningful value there (unreadable), so the tests need both.
+func f64(v float64) *float64 { return &v }
+func i64(v int64) *int64 { return &v }
+
+func TestOverviewLiveVersionFromStable(t *testing.T) {
+ promoted := time.Now().Add(-3 * time.Hour)
+ view := &AppView{App: "kass", Channels: []ChannelView{
+ {Name: "beta", Version: "v9"},
+ {Name: "stable", Version: "v7", PromotedAt: promoted, Actor: "kim@keymail.dev"},
+ }}
+ st := assembleOverview(view, nil, nil)
+ if !st.HasStable {
+ t.Fatal("an app with a stable pointer is not reported live")
+ }
+ if st.LiveVersion != "v7" {
+ t.Fatalf("LiveVersion = %q, want v7 (beta's v9 is not what users get)", st.LiveVersion)
+ }
+ if !st.LivePromotedAt.Equal(promoted) || st.LiveActor != "kim@keymail.dev" {
+ t.Fatalf("promoted %v by %q, want %v by kim@keymail.dev", st.LivePromotedAt, st.LiveActor, promoted)
+ }
+ if st.LiveErr != "" {
+ t.Fatalf("LiveErr = %q on a readable pointer", st.LiveErr)
+ }
+
+ // No stable at all: the honest "not live yet" state, and NOT an error.
+ st = assembleOverview(&AppView{App: "kass", Channels: []ChannelView{{Name: "edge", Version: "v9"}}}, nil, nil)
+ if st.HasStable || st.LiveVersion != "" {
+ t.Fatalf("a never-promoted app reads as live: %+v", st)
+ }
+ if st.LiveErr != "" {
+ t.Fatalf("LiveErr = %q, want no error for an app that simply has no stable", st.LiveErr)
+ }
+}
+
+// Unknown is not the same as not live. A stable pointer that will not verify
+// says so — claiming "not live" would tell an owner their app is down when the
+// only thing broken is this console's read of it.
+func TestOverviewUnreadableStableIsUnknownNotOffline(t *testing.T) {
+ view := &AppView{App: "kass", Channels: []ChannelView{
+ {Name: "stable", Err: "signature does not verify"},
+ }}
+ st := assembleOverview(view, nil, nil)
+ if st.HasStable {
+ t.Fatal("an unreadable pointer was reported as a known live version")
+ }
+ if st.LiveErr != "signature does not verify" {
+ t.Fatalf("LiveErr = %q, want the pointer's own error text", st.LiveErr)
+ }
+ if st.LiveVersion != "" {
+ t.Fatalf("LiveVersion = %q, want nothing claimed from an unreadable pointer", st.LiveVersion)
+ }
+}
+
+// The tiles render an em-dash for "no readings", never zeros — so the sums
+// have to stay nil when nothing reported, and must cover only the instances
+// that actually did.
+func TestOverviewSumsOnlyReportingInstances(t *testing.T) {
+ metrics := []InstanceMetrics{
+ {Host: "a", Box: "box-1", CPUFrac: f64(0.25), MemBytes: i64(100)},
+ {Host: "b", Box: "box-1", CPUFrac: f64(0.5), MemBytes: i64(200)},
+ // Reporting, but this metric was unreadable: contributes nothing.
+ {Host: "c", Box: "box-2"},
+ // Not reporting at all, with numbers that must NOT be counted.
+ {Host: "d", CPUFrac: f64(99), MemBytes: i64(99), Hibernate: true},
+ {Host: "e"},
+ }
+ st := assembleOverview(&AppView{App: "kass"}, metrics, nil)
+ if st.InstanceTotal != 5 {
+ t.Fatalf("InstanceTotal = %d, want 5", st.InstanceTotal)
+ }
+ if st.InstanceReporting != 3 {
+ t.Fatalf("InstanceReporting = %d, want 3", st.InstanceReporting)
+ }
+ if st.InstanceAsleep != 1 {
+ t.Fatalf("InstanceAsleep = %d, want only the hibernating non-reporter", st.InstanceAsleep)
+ }
+ if st.CPUSum == nil || *st.CPUSum != 0.75 {
+ t.Fatalf("CPUSum = %v, want 0.75 — the non-reporters must not be summed", st.CPUSum)
+ }
+ if st.MemSum == nil || *st.MemSum != 300 {
+ t.Fatalf("MemSum = %v, want 300", st.MemSum)
+ }
+
+ // Nobody reported: nil, not zero. A zero here would render as "0%" and
+ // claim an idle app where there is simply no reading.
+ quiet := assembleOverview(&AppView{App: "kass"}, []InstanceMetrics{{Host: "a"}, {Host: "b"}}, nil)
+ if quiet.CPUSum != nil || quiet.MemSum != nil {
+ t.Fatalf("sums = %v %v with nothing reporting, want nil so the tiles show the em-dash",
+ quiet.CPUSum, quiet.MemSum)
+ }
+ if quiet.InstanceReporting != 0 || quiet.InstanceTotal != 2 {
+ t.Fatalf("quiet counts = %d/%d, want 0/2", quiet.InstanceReporting, quiet.InstanceTotal)
+ }
+}
+
+// Attention means someone has to do something: DNS that is not pointing here,
+// or issuance that failed. A cert mid-issuance is patience, not attention —
+// badging it would make every new domain look broken for its first minute.
+func TestOverviewDomainAttentionCountsGuidanceAndFailedTLS(t *testing.T) {
+ view := &AppView{App: "kass", Domains: []DomainView{
+ {Domain: "a.example.com", TLS: "ready · renews 2027-01-01"},
+ {Domain: "b.example.com", TLS: "issuing"},
+ {Domain: "c.example.com", TLS: ""},
+ {Domain: "d.example.com", TLS: "failed: dns-01 timeout"},
+ {Domain: "e.example.com", TLS: "ready", Guidance: "Point an A record for e.example.com at ..."},
+ }}
+ st := assembleOverview(view, nil, nil)
+ if st.DomainTotal != 5 {
+ t.Fatalf("DomainTotal = %d, want 5", st.DomainTotal)
+ }
+ if st.DomainAttention != 2 {
+ t.Fatalf("DomainAttention = %d, want 2 (the failed cert and the unpointed domain)", st.DomainAttention)
+ }
+ // The one-bit read on its own, so a miscount above is attributable.
+ for _, tc := range []struct {
+ name string
+ d DomainView
+ want bool
+ }{
+ {"ready", DomainView{TLS: "ready · renews 2027-01-01"}, false},
+ {"issuing", DomainView{TLS: "issuing"}, false},
+ {"unknown", DomainView{TLS: ""}, false},
+ {"failed", DomainView{TLS: "failed: dns-01 timeout"}, true},
+ {"guidance", DomainView{TLS: "ready", Guidance: "point an A record"}, true},
+ } {
+ if got := domainNeedsAttention(tc.d); got != tc.want {
+ t.Errorf("domainNeedsAttention(%s) = %v, want %v", tc.name, got, tc.want)
+ }
+ }
+}
+
+// The versions list is capped, so the tile says "20+" rather than lying with
+// a flat 20 — and the latest version is the newest, which is the list's head.
+func TestOverviewReleasesCapped(t *testing.T) {
+ full := make([]VersionView, versionsCap)
+ for i := range full {
+ full[i] = VersionView{Version: "v" + string(rune('a'+i))}
+ }
+ st := assembleOverview(&AppView{App: "kass"}, nil, full)
+ if st.ReleaseCount != versionsCap || !st.ReleasesCapped {
+ t.Fatalf("count/capped = %d/%v, want %d/true", st.ReleaseCount, st.ReleasesCapped, versionsCap)
+ }
+ if st.LatestVersion != full[0].Version {
+ t.Fatalf("LatestVersion = %q, want the head of the newest-first list (%q)", st.LatestVersion, full[0].Version)
+ }
+
+ short := assembleOverview(&AppView{App: "kass"}, nil, full[:versionsCap-1])
+ if short.ReleasesCapped {
+ t.Fatalf("a list one short of the cap was reported capped (%d)", short.ReleaseCount)
+ }
+ none := assembleOverview(&AppView{App: "kass"}, nil, nil)
+ if none.ReleaseCount != 0 || none.ReleasesCapped || none.LatestVersion != "" {
+ t.Fatalf("empty release list = %+v, want zeros and no version", none)
+ }
+}
+
+// The join key is the host — the one name owners have for an instance — and
+// an unswept route still gets its row, with Reporting false rather than zeros.
+func TestInstanceRowsJoinMetricsByHost(t *testing.T) {
+ acct := &Account{ID: 1, Sqid: "bab"}
+ view := &AppView{App: "kass", Routes: []RouteView{
+ {Host: "a.example.com", Account: "bab", Channel: "stable", Version: "v1", Kind: "instance", Status: "ready"},
+ {Host: "b.example.com", Account: "bab", Channel: "canary/x", Kind: "instance", Status: "", Hibernate: true},
+ }}
+ metrics := []InstanceMetrics{
+ {Host: "b.example.com", Box: "box-1", CPUFrac: f64(0.1)},
+ {Host: "ghost.example.com", Box: "box-1", CPUFrac: f64(9)},
+ }
+ rows := assembleInstanceRows(view, acct, metrics)
+ if len(rows) != 2 {
+ t.Fatalf("rows = %+v, want one per route (a metrics row for an unrouted host adds nothing)", rows)
+ }
+ if rows[0].Host != "a.example.com" || rows[0].Metrics.Reporting() {
+ t.Fatalf("row[0] = %+v, want the unswept route present and not reporting", rows[0])
+ }
+ // The registry contract: an empty status means ready, and the table must
+ // not print a blank cell for the commonest state there is.
+ if rows[1].Status != "ready" {
+ t.Fatalf("row[1].Status = %q, want the empty registry status rendered as \"ready\"", rows[1].Status)
+ }
+ if rows[0].Status != "ready" {
+ t.Fatalf("row[0].Status = %q, want its own explicit status kept", rows[0].Status)
+ }
+ if !rows[1].Hibernate {
+ t.Fatal("the route's hibernation setting did not reach its row")
+ }
+ if !rows[1].Metrics.Reporting() || rows[1].Metrics.CPUFrac == nil || *rows[1].Metrics.CPUFrac != 0.1 {
+ t.Fatalf("row[1].Metrics = %+v, want the sweep's reading joined by host", rows[1].Metrics)
+ }
+ if rows[0].Channel != "stable" || rows[1].Channel != "canary/x" || rows[0].Version != "v1" {
+ t.Fatalf("rows lost their route facts: %+v", rows)
+ }
+}
+
+// The tenant boundary, kept byte-for-byte with appResourceMetrics': routes are
+// grouped by app NAME and app names are unique per ACCOUNT, so another
+// account's route can legitimately arrive in this view — and must not become a
+// row on this page.
+func TestInstanceRowsSkipOtherAccountsRoutes(t *testing.T) {
+ acct := &Account{ID: 1, Sqid: "bab"}
+ view := &AppView{App: "kass", Routes: []RouteView{
+ {Host: "mine.example.com", Account: "bab", Channel: "stable"},
+ {Host: "theirs.example.com", Account: "cdef", Channel: "stable"},
+ }}
+ metrics := []InstanceMetrics{{Host: "theirs.example.com", Box: "box-9", CPUFrac: f64(0.9)}}
+ rows := assembleInstanceRows(view, acct, metrics)
+ if len(rows) != 1 || rows[0].Host != "mine.example.com" {
+ t.Fatalf("rows = %+v, want only the viewing account's own route", rows)
+ }
+}
+
+// The Overview page is the tiles, and only the tiles: the ladder moved to the
+// Releases tab, and a copy left behind here would be two places to read the
+// same state from.
+func TestOverviewPageRendersTiles(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+
+ status, _, body := get(t, c, fx.u("/apps/kass"))
+ if status != http.StatusOK {
+ t.Fatalf("overview page = %d:\n%s", status, body)
+ }
+ if !strings.Contains(body, `class="tile"`) {
+ t.Fatalf("no tiles on the Overview:\n%s", body)
+ }
+ // The fixture's stable holds v1 — the number the "Live now" tile exists
+ // to show.
+ if !strings.Contains(body, "Live now") || !strings.Contains(body, "<code>v1</code>") {
+ t.Fatalf("the Live now tile does not show what stable serves:\n%s", body)
+ }
+ if !strings.Contains(body, "on stable · promoted") {
+ t.Fatalf("the Live now tile does not say when it was promoted:\n%s", body)
+ }
+ if strings.Contains(body, `id="rung-`) {
+ t.Fatalf("the ladder is still rendered on the Overview:\n%s", body)
+ }
+}
+
+// The Instances tab's one table, with the columns an owner reads across.
+func TestOverviewInstancesPageRendersTable(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+
+ status, _, body := get(t, c, fx.u("/apps/kass/instances"))
+ if status != http.StatusOK {
+ t.Fatalf("instances page = %d:\n%s", status, body)
+ }
+ for _, th := range []string{"Instance", "Channel", "Running", "Status", "CPU", "Memory"} {
+ if !strings.Contains(body, "<th>"+th+"</th>") {
+ t.Fatalf("the instances table has no %s column:\n%s", th, body)
+ }
+ }
+ // And the fixture's own routes are the rows — a header-only table would
+ // pass every assertion above.
+ if !strings.Contains(body, "kass.carlosapp.dev") || !strings.Contains(body, "<code>stable</code>") {
+ t.Fatalf("the app's routes are not listed in the table:\n%s", body)
+ }
+}
diff --git a/internal/console/server_account_test.go b/internal/console/server_account_test.go
index 1eae5aa5e..700346616 100644
--- a/internal/console/server_account_test.go
+++ b/internal/console/server_account_test.go
@@ -658,7 +658,10 @@ func TestAuditIsScopedToTheAccount(t *testing.T) {
// account's keys while every host reads another's. "Promoted v2 to stable"
// flashes green and nothing deploys, forever, with no error anywhere.
//
-// So the app page says so, and names the command that fixes it.
+// So the app page says so, and points at the fix: the overview and releases
+// tabs both link to Settings, which is where the repair itself now lives
+// (appsettings.go) — a button when the signed-in owner can reach the target
+// account, the operator command as a fallback when they cannot.
func TestAppPageWarnsWhenAccountsDisagree(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
@@ -692,8 +695,8 @@ func TestAppPageWarnsWhenAccountsDisagree(t *testing.T) {
if !strings.Contains(body, "not deploying") {
t.Errorf("no warning on a half-migrated app:\n%s", body)
}
- if !strings.Contains(body, "console migrate-app -app kass -to "+sqid) {
- t.Errorf("the warning does not name the command that fixes it:\n%s", body)
+ if !strings.Contains(body, `/apps/kass/settings"`) {
+ t.Errorf("the warning does not link to the Settings tab, where the fix now lives:\n%s", body)
}
// The promote/rollback/canary forms live on the releases tab, not the
@@ -703,8 +706,19 @@ func TestAppPageWarnsWhenAccountsDisagree(t *testing.T) {
if !strings.Contains(body, "not deploying") {
t.Errorf("no warning on the releases tab of a half-migrated app:\n%s", body)
}
- if !strings.Contains(body, "console migrate-app -app kass -to "+sqid) {
- t.Errorf("the releases tab warning does not name the command that fixes it:\n%s", body)
+ if !strings.Contains(body, `/apps/kass/settings"`) {
+ t.Errorf("the releases tab warning does not link to the Settings tab, where the fix now lives:\n%s", body)
+ }
+
+ // Settings itself holds the actual repair. paul owns the target account
+ // here (CreateAccount above makes its creator the owner), so he can reach
+ // it — the button, not the operator-command fallback, is what he's owed.
+ _, _, body = get(t, c, fx.u("/apps/kass/settings"))
+ if !strings.Contains(body, "Move to "+sqid) {
+ t.Errorf("Settings does not offer the move-to-target button for an owner who can reach it:\n%s", body)
+ }
+ if !strings.Contains(body, `action="`+fx.path("/apps/kass/move")+`"`) {
+ t.Errorf("Settings' move button does not post to the move endpoint:\n%s", body)
}
}
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index 00a93b3d9..dc989f658 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -244,14 +244,15 @@ func TestConsoleE2E(t *testing.T) {
t.Fatal(err)
}
- // Overview: the ladder, with channel names and versions on it.
- status, _, body := get(t, c, fx.u("/apps/kass"))
+ // 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 = %d", status)
+ 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 missing %q in:\n%s", want, body)
+ t.Fatalf("/apps/kass/releases missing %q in:\n%s", want, body)
}
}
@@ -440,7 +441,7 @@ func TestCreateApp(t *testing.T) {
if status != 200 {
t.Fatalf("/apps/zephyr = %d", status)
}
- for _, want := range []string{"Created app zephyr", "0 releases"} {
+ 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)
}
@@ -521,12 +522,16 @@ func TestOverviewIsALadderNotAnEverythingPage(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
_, _, body := get(t, c, fx.u("/apps/kass"))
- for _, gone := range []string{"<h2>Channels</h2>", "<h2>Releases</h2>", "<h2>Recent activity</h2>", "<h2>Domains</h2>", "<h2>Resources</h2>"} {
+ // 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"`, "<table", "<h2>Channels</h2>", "<h2>Canaries</h2>"} {
if strings.Contains(body, gone) {
t.Fatalf("overview still renders %q", gone)
}
}
- for _, want := range []string{`class="ladder"`, "domain", "release"} {
+ for _, want := range []string{`class="tiles stat-tiles"`, "Releases", "Domains", "Instances"} {
if !strings.Contains(body, want) {
t.Fatalf("overview missing %q", want)
}
@@ -747,7 +752,7 @@ func TestPromoteFlow(t *testing.T) {
// 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"}})
+ 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)
}
@@ -765,7 +770,7 @@ func TestPromoteFlow(t *testing.T) {
// 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"}})
+ 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)
}
@@ -777,14 +782,14 @@ func TestPromoteFlow(t *testing.T) {
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"}})
+ 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"}})
+ 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)
}
@@ -845,7 +850,7 @@ func TestRollbackFlow(t *testing.T) {
t.Fatal("no rollback button after stable gained history")
}
status, loc, _ := postForm(t, c, fx.u("/apps/kass/rollback"),
- url.Values{"csrf": {csrf}, "channel": {"stable"}})
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "confirmed": {"1"}})
if status != http.StatusSeeOther || loc != fx.path("/apps/kass") {
t.Fatalf("rollback = %d -> %q", status, loc)
}
@@ -861,7 +866,7 @@ func TestRollbackFlow(t *testing.T) {
// 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, _ = postForm(t, c, fx.u("/apps/kass/rollback"),
- url.Values{"csrf": {csrf}, "channel": {"stable"}, "back": {"releases"}})
+ 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"))
}
@@ -962,8 +967,10 @@ func TestCanaryURLFromRoute(t *testing.T) {
if !strings.Contains(body, "live at https://rc.kass.carlosapp.dev") {
t.Fatalf("create flash has no canary URL:\n%s", body)
}
- if !strings.Contains(body, `<a href="https://rc.kass.carlosapp.dev">rc.kass.carlosapp.dev</a>`) {
- t.Fatalf("canary table has no host link:\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, `<a href="https://rc.kass.carlosapp.dev">rc.kass.carlosapp.dev</a>`) {
+ t.Fatalf("canary table has no host link:\n%s", rbody)
}
}
@@ -992,7 +999,7 @@ func TestRetireCanary(t *testing.T) {
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"}})
+ 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)
}
@@ -1054,10 +1061,11 @@ func TestAppPageInstances(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
- // Before any reporting route exists: no Instances section — the tab is
- // exactly the route-level view.
- if _, _, body := get(t, c, fx.u("/apps/kass/instances")); strings.Contains(body, "<h2>Instances</h2>") {
- t.Fatal("Instances section rendered with nothing reporting")
+ // Before the app reports its own internal instances: no "Inside the app"
+ // section — the tab is exactly the route-level view (which always
+ // renders its own "Instances" header, fixture routes and all).
+ if _, _, body := get(t, c, fx.u("/apps/kass/instances")); strings.Contains(body, "<h2>Inside the app</h2>") {
+ t.Fatal("\"Inside the app\" section rendered with nothing reporting")
}
// Kass starts reporting its three training instances.
@@ -1066,7 +1074,7 @@ func TestAppPageInstances(t *testing.T) {
Status: "ready", App: "kass"})
_, _, body := get(t, c, fx.u("/apps/kass/instances"))
for _, want := range []string{
- "<h2>Instances</h2>", "reports 3 internal instances",
+ "<h2>Instances</h2>", "<h2>Inside the app</h2>", "manages its own internal instances",
"riders-2026", "spring-camp", "trial-run",
"eu-west-1", "archived", "read-only since June",
} {
@@ -1096,13 +1104,13 @@ func TestRecentActivity(t *testing.T) {
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"}})
+ url.Values{"csrf": {csrf}, "version": {"v3"}, "new_channel": {"edge"}, "confirmed": {"1"}})
postForm(t, c, fx.u("/apps/kass/rollback"),
- url.Values{"csrf": {csrf}, "channel": {"stable"}})
+ 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"}})
+ url.Values{"csrf": {csrf}, "name": {"rc"}, "confirmed": {"1"}})
_, _, body := get(t, c, fx.u("/apps/kass/activity"))
for _, want := range []string{
@@ -1734,10 +1742,14 @@ func TestAppPageOwnerMetricsBoundary(t *testing.T) {
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) — each phrase below is
+ // unique to the one fixture route it describes, so checking presence
+ // page-wide still pins it to that route.
for _, want := range []string{
- "Resources", "owned.example.com", "11%", "1.0 KB",
- "asleep.example.com</code> — asleep — no recent readings",
- "unmetered.example.com</code> — no per-instance readings",
+ "owned.example.com", "11%", "1.0 KB",
+ "asleep.example.com", "asleep — no recent readings",
+ "unmetered.example.com", "no per-instance readings",
} {
if !strings.Contains(body, want) {
t.Fatalf("app page missing %q in:\n%s", want, body)
@@ -1771,12 +1783,12 @@ func TestAppPageSurvivesMetricsFailure(t *testing.T) {
if !strings.Contains(body, `aria-current="page">Instances</a>`) {
t.Fatalf("instances tab did not render its normal chrome: %s", body)
}
- // Content, not just chrome: with the metrics read failing closed
- // (appResourceMetrics returns nil) and the fixture route unreachable
- // (Instances.Reporting false), the tab's real nil-metrics empty state
- // is what a reader sees — not a partially-rendered page.
- if !strings.Contains(body, "Nothing to show yet") {
- t.Fatalf("instances tab did not render its nil-metrics empty state: %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 per-instance readings" in place
+ // of numbers, not a partially-rendered page.
+ if !strings.Contains(body, "owned.example.com") || !strings.Contains(body, "no per-instance readings") {
+ t.Fatalf("instances tab did not render its route row with the metrics-failed state: %s", body)
}
}
@@ -1852,15 +1864,19 @@ func TestAppPageOwnerMetricsCrossAccountAppName(t *testing.T) {
}
// 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, "<h2>Resources</h2>")
+ i := strings.Index(body, "<h2>Instances</h2>")
if i < 0 {
- t.Fatalf("no Resources section in:\n%s", body)
+ t.Fatalf("no Instances section in:\n%s", body)
}
j := strings.Index(body[i:], "</main>")
if j < 0 {
- t.Fatalf("Resources section never ended:\n%s", body[i:])
+ t.Fatalf("Instances section never ended:\n%s", body[i:])
}
return body[i : i+j]
}
diff --git a/internal/console/store.go b/internal/console/store.go
index cc62b7ab2..1a4512c60 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -1273,7 +1273,7 @@ func (s *Store) RenameAppIn(accountID int64, from, to string) error {
if taken > 0 {
return fmt.Errorf("console: this account already has an app (or a trashed app) named %q", to)
}
- res, err := tx.Exec(`UPDATE apps SET app = ? WHERE account_id = ? AND app = ? AND deleted_at IS NULL`,
+ res, err := tx.Exec(`UPDATE apps SET app = ? WHERE account_id = ? AND app = ? AND deleted_at = 0`,
to, accountID, from)
if err != nil {
return err
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html
index affd9aad1..4500abd08 100644
--- a/internal/console/templates/app_overview.html
+++ b/internal/console/templates/app_overview.html
@@ -14,7 +14,7 @@
</p>
{{end}}
{{with .Overview}}
-<section class="tiles" aria-label="Vital statistics">
+<section class="tiles stat-tiles" aria-label="Vital statistics">
<a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/releases">
<h2>Live now</h2>
{{if .HasStable}}
@@ -70,7 +70,7 @@
<h2>Recent activity</h2>
<ul class="feed">
{{range .Events}}
- <li><span class="small" title="{{fmtTime .At}}">{{ago .At}}</span> {{.Actor}} — {{.Detail}}</li>
+ <li><span class="small" title="{{fmtTime .CreatedAt}}">{{ago .CreatedAt}}</span> {{.Address}} — {{.Detail}}</li>
{{end}}
</ul>
<p class="small"><a href="{{.Nav}}/apps/{{.AppName}}/activity">All activity</a></p>
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index 41ea9a67d..362bdd63b 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -44,6 +44,10 @@
border: 1px solid var(--rule); border-radius: 0.4rem; padding: 0.3rem 0.5rem;
}
td form { display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; margin: 0; }
+ /* A form living inside a sentence (the bake toggle, a rung's fast-track):
+ it must not break the line it is part of. */
+ form.inline { display: inline; margin: 0; }
+ form.inline button { vertical-align: baseline; }
/* Interface icons: inline SVG from internal/console/icons.go — Lucide,
vendored, because this page fetches nothing from anywhere. They carry no
width/height of their own, so they size to the text beside them and
@@ -140,6 +144,9 @@
the affordance, so no underline and no accent ink — hover carries it. */
a.tile { color: var(--ink); text-decoration: none; }
a.tile:hover { outline: 1px solid var(--accent); }
+ /* The dashboard's six statistics as an even grid — flex would let the
+ last row's stragglers stretch full-width. */
+ .stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); }
.tile h2 {
margin: 0 0 0.35rem; font-family: var(--mono); font-size: 0.78rem; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--muted); font-weight: 600;