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>
11 files changed,
+1543
−82
internal/console/appsettings_test.go+657 −0internal/console/bakecontrols_test.go+6 −6internal/console/confirm_test.go+314 −0internal/console/ladder_test.go+44 −32internal/console/metricsview_test.go+162 −0internal/console/overview_test.go+279 −0internal/console/server_account_test.go+19 −5internal/console/server_test.go+52 −36internal/console/store.go+1 −1internal/console/templates/app_overview.html+2 −2internal/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; |