carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3// The app Settings tab's write half (appsettings.go): rename, delete, and the
4// account-move repair, plus the store verb rename is built on.
5//
6// The theme running through all of it is that this console may only change its
7// OWN record. Every test here therefore checks two things about a refusal: that
8// the person is told, and that nothing outside this database moved — because
9// the whole reason renaming is refused after a ship, and deletion is refused on
10// a production app, is that the other stores would not follow.
11
12import (
13 "context"
14 "errors"
15 "github.com/carlosframework/platform/internal/certwarm"
16 "github.com/carlosframework/platform/internal/domains"
17 "github.com/carlosframework/platform/internal/status"
18 "net/http"
19 "net/url"
20 "strings"
21 "testing"
22 "time"
23
24 "github.com/carlosframework/platform/internal/dnsrec"
25 "github.com/carlosframework/platform/internal/mail"
26 "github.com/carlosframework/platform/internal/objstore"
27 "github.com/carlosframework/platform/internal/registry"
28 "github.com/carlosframework/platform/internal/release"
29)
30
31// ---- Store.RenameAppIn ------------------------------------------------------
32
33// renameStore is an account with one app in it — the smallest world a rename
34// means anything in.
35func renameStore(t *testing.T, app string) (*Store, int64) {
36 t.Helper()
37 s := openTestStore(t)
38 id := testAccountID()
39 if err := s.CreateAccount(id, "rn"+app, "Renameco", "paul@keymail.dev", time.Now()); err != nil {
40 t.Fatal(err)
41 }
42 if created, err := s.CreateAppIn(id, app, "paul@keymail.dev", time.Now()); err != nil || !created {
43 t.Fatalf("CreateAppIn = %v %v", created, err)
44 }
45 return s, id
46}
47
48// The happy path, and the half that is easy to forget: the app's audit history
49// follows it. Its story belongs to the app, not to a name that no longer
50// resolves — a rename that stranded the trail would leave the Activity tab of
51// the renamed app blank and the events unreachable under any name.
52//
53// RED as written (2026-08-07): RenameAppIn's UPDATE is guarded by
54// `deleted_at IS NULL`, and apps.deleted_at is `INTEGER NOT NULL DEFAULT 0` —
55// so the guard matches no row, ever, and every rename returns "this account
56// does not claim %q". The guard the rest of the file uses for "live" is
57// `deleted_at = 0`.
58func TestRenameAppInStoreRenamesRowAndAudit(t *testing.T) {
59 s, id := renameStore(t, "alpha")
60 if err := s.RecordEvent(id, "alpha", "paul@keymail.dev", "create", "created app alpha", time.Now()); err != nil {
61 t.Fatal(err)
62 }
63
64 if err := s.RenameAppIn(id, "alpha", "beta"); err != nil {
65 t.Fatalf("RenameAppIn(alpha -> beta) = %v, want the rename to land", err)
66 }
67 if has, err := s.HasAppIn(id, "beta"); err != nil || !has {
68 t.Fatalf("HasAppIn(beta) = %v %v, want the row under its new name", has, err)
69 }
70 if has, err := s.HasAppIn(id, "alpha"); err != nil || has {
71 t.Fatalf("HasAppIn(alpha) = %v %v, want the old name gone", has, err)
72 }
73 moved, err := s.Events(id, "beta", 10)
74 if err != nil {
75 t.Fatal(err)
76 }
77 if len(moved) != 1 || moved[0].Detail != "created app alpha" {
78 t.Fatalf("Events(beta) = %+v, want the app's history to have followed it", moved)
79 }
80 left, err := s.Events(id, "alpha", 10)
81 if err != nil {
82 t.Fatal(err)
83 }
84 if len(left) != 0 {
85 t.Fatalf("Events(alpha) = %+v, want nothing left under the old name", left)
86 }
87}
88
89// The name must be free in this account — and a TRASHED row still owns its
90// name until it is purged, exactly as CreateAppIn treats it. Renaming onto one
91// would resurrect a collision the moment the trashed app was restored.
92func TestRenameAppInRefusesTakenNames(t *testing.T) {
93 s, id := renameStore(t, "alpha")
94 if _, err := s.CreateAppIn(id, "live", "paul@keymail.dev", time.Now()); err != nil {
95 t.Fatal(err)
96 }
97 if _, err := s.CreateAppIn(id, "binned", "paul@keymail.dev", time.Now()); err != nil {
98 t.Fatal(err)
99 }
100 if err := s.TrashAppRow(id, "binned", time.Now()); err != nil {
101 t.Fatal(err)
102 }
103 for _, to := range []string{"live", "binned"} {
104 err := s.RenameAppIn(id, "alpha", to)
105 if err == nil {
106 t.Fatalf("RenameAppIn(alpha -> %s) succeeded onto a claimed name", to)
107 }
108 if !strings.Contains(err.Error(), "already has an app") {
109 t.Fatalf("RenameAppIn(alpha -> %s) = %v, want the taken-name refusal", to, err)
110 }
111 // And the refusal is total: nothing moved.
112 if has, err := s.HasAppIn(id, "alpha"); err != nil || !has {
113 t.Fatalf("HasAppIn(alpha) = %v %v after a refused rename, want it untouched", has, err)
114 }
115 }
116}
117
118// Legacy owners-table grants are keyed by BARE app name across all accounts, so
119// rewriting one could hand an account's grant to another, and renaming ONTO one
120// would let the new name inherit a grant nobody issued it. Both directions are
121// refused.
122func TestRenameAppInRefusesLegacyGrants(t *testing.T) {
123 s, id := renameStore(t, "alpha")
124 if err := s.Grant("paul@keymail.dev", "alpha"); err != nil {
125 t.Fatal(err)
126 }
127 if err := s.RenameAppIn(id, "alpha", "gamma"); err == nil ||
128 !strings.Contains(err.Error(), "legacy owner grant") {
129 t.Fatalf("renaming FROM a granted name = %v, want the legacy-grant refusal", err)
130 }
131 if err := s.Revoke("paul@keymail.dev", "alpha"); err != nil {
132 t.Fatal(err)
133 }
134 if err := s.Grant("paul@keymail.dev", "gamma"); err != nil {
135 t.Fatal(err)
136 }
137 if err := s.RenameAppIn(id, "alpha", "gamma"); err == nil ||
138 !strings.Contains(err.Error(), "legacy owner grant") {
139 t.Fatalf("renaming ONTO a granted name = %v, want the legacy-grant refusal", err)
140 }
141 if has, err := s.HasAppIn(id, "alpha"); err != nil || !has {
142 t.Fatalf("HasAppIn(alpha) = %v %v after the refusals, want it untouched", has, err)
143 }
144}
145
146// Renaming something this account does not claim is an error, not a silent
147// no-op — the handler turns it into "could not rename", and a quiet success
148// would tell the owner an app moved when nothing did.
149func TestRenameAppInRefusesMissingSource(t *testing.T) {
150 s, id := renameStore(t, "alpha")
151 err := s.RenameAppIn(id, "ghost", "gamma")
152 if err == nil {
153 t.Fatal("renaming an app this account does not claim reported success")
154 }
155 if !strings.Contains(err.Error(), "does not claim") {
156 t.Fatalf("RenameAppIn(ghost -> gamma) = %v, want the unclaimed-source refusal", err)
157 }
158 if has, err := s.HasAppIn(id, "gamma"); err != nil || has {
159 t.Fatalf("HasAppIn(gamma) = %v %v, want no row conjured by the failed rename", has, err)
160 }
161}
162
163// ---- handleAppRename --------------------------------------------------------
164
165// freshApp claims an app name in the fixture account with nothing behind it:
166// no bucket keys, no routes. That is the ONLY state a rename is offered in, so
167// it is the state most of these tests start from.
168func freshApp(t *testing.T, fx *consoleFixture, app string) {
169 t.Helper()
170 if created, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", time.Now()); err != nil || !created {
171 t.Fatalf("CreateAppIn(%s) = %v %v", app, created, err)
172 }
173}
174
175// shipSomething puts one object under the app's bucket prefix. Which object is
176// beside the point: appDeployEmpty refuses on ANY key, because one key is
177// enough to prove the name is baked into a storage layer this handler cannot
178// rewrite.
179func shipSomething(t *testing.T, fx *consoleFixture, app string) {
180 t.Helper()
181 key := release.AppPrefix(fx.sqid, app) + "releases/v1/manifest.json"
182 if _, err := fx.rel.Put(context.Background(), key, []byte(`{"schema":1}`), ""); err != nil {
183 t.Fatal(err)
184 }
185}
186
187// The card, then the rename: a fresh app's name is still only a record here,
188// so the whole ceremony is confirm-and-go, with no passkey.
189//
190// RED as written (2026-08-07) for the same reason as
191// TestRenameAppInStoreRenamesRowAndAudit: the store verb underneath cannot
192// match a live row, so the confirmed POST flashes "Could not rename" and
193// lands back on the OLD settings URL. The card half of this test is green.
194func TestRenameHandlerConfirmsThenRenames(t *testing.T) {
195 fx := newConsoleFixture(t)
196 freshApp(t, fx, "sprout")
197 c := signInPaul(t, fx)
198 csrf := sessionToken(t, fx, c)
199 form := url.Values{"csrf": {csrf}, "to": {"sapling"}}
200
201 status, loc, body := postForm(t, c, fx.u("/apps/sprout/rename"), form)
202 if strings.HasPrefix(loc, fx.fk.srv.URL) {
203 t.Fatalf("renaming a never-shipped app demanded a passkey (-> %q)", loc)
204 }
205 card := confirmCard(t, status, body, "Rename sprout to sapling?", csrf)
206 if !strings.Contains(card, `<input type="hidden" name="to" value="sapling">`) {
207 t.Fatalf("the card does not carry the new name:\n%s", card)
208 }
209 if has, err := fx.st.HasAppIn(OpsAccountID, "sprout"); err != nil || !has {
210 t.Fatalf("HasAppIn(sprout) = %v %v after the card, want it unrenamed", has, err)
211 }
212
213 form.Set("confirmed", "1")
214 status, loc, body = postForm(t, c, fx.u("/apps/sprout/rename"), form)
215 if status != http.StatusSeeOther || loc != fx.path("/apps/sapling/settings") {
216 t.Fatalf("confirmed rename = %d -> %q, want 303 to the NEW settings URL:\n%s", status, loc, body)
217 }
218 if has, err := fx.st.HasAppIn(OpsAccountID, "sapling"); err != nil || !has {
219 t.Fatalf("HasAppIn(sapling) = %v %v, want the app under its new name", has, err)
220 }
221 if has, err := fx.st.HasAppIn(OpsAccountID, "sprout"); err != nil || has {
222 t.Fatalf("HasAppIn(sprout) = %v %v, want the old name gone", has, err)
223 }
224 if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "Renamed sprout to sapling.") {
225 t.Fatalf("no rename flash:\n%s", page)
226 }
227 auditedBy(t, fx, "sapling", "rename", "paul@keymail.dev", "renamed sprout to sapling")
228}
229
230// One key under the app's prefix is enough. The refusal is a flash, not a card:
231// the handler decided before asking, so there is nothing to confirm.
232func TestRenameHandlerRefusesShippedApp(t *testing.T) {
233 fx := newConsoleFixture(t)
234 freshApp(t, fx, "shipped")
235 shipSomething(t, fx, "shipped")
236 c := signInPaul(t, fx)
237 csrf := sessionToken(t, fx, c)
238
239 status, loc, body := postForm(t, c, fx.u("/apps/shipped/rename"),
240 url.Values{"csrf": {csrf}, "to": {"renamed"}, "confirmed": {"1"}})
241 if status != http.StatusSeeOther || loc != fx.path("/apps/shipped/settings") {
242 t.Fatalf("rename of a shipped app = %d -> %q, want the flash redirect:\n%s", status, loc, body)
243 }
244 notACard(t, body)
245 if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "This app has shipped") {
246 t.Fatalf("no shipped refusal flash:\n%s", page)
247 }
248 if has, err := fx.st.HasAppIn(OpsAccountID, "shipped"); err != nil || !has {
249 t.Fatalf("HasAppIn(shipped) = %v %v, want the app untouched", has, err)
250 }
251 if has, err := fx.st.HasAppIn(OpsAccountID, "renamed"); err != nil || has {
252 t.Fatalf("HasAppIn(renamed) = %v %v, want no new row", has, err)
253 }
254}
255
256// A route wearing the name is the other wider store: the name is the address.
257// Checked at ACTION time, not from the page's advice — a route added since the
258// page rendered must still stop the rename.
259func TestRenameHandlerRefusesRoutedApp(t *testing.T) {
260 fx := newConsoleFixture(t)
261 freshApp(t, fx, "wired")
262 c := signInPaul(t, fx)
263 csrf := sessionToken(t, fx, c)
264 // Added AFTER the app was claimed and with nothing in the bucket, so the
265 // deploy-empty check passes and the ROUTE check is what refuses.
266 fx.addRoute(t, registry.Route{Host: "wired.carlosapp.dev", Kind: "instance", Status: "ready",
267 App: "wired", Channel: "stable", Addr: "127.0.0.1:9031"})
268
269 status, loc, body := postForm(t, c, fx.u("/apps/wired/rename"),
270 url.Values{"csrf": {csrf}, "to": {"unwired"}, "confirmed": {"1"}})
271 if status != http.StatusSeeOther || loc != fx.path("/apps/wired/settings") {
272 t.Fatalf("rename of a routed app = %d -> %q:\n%s", status, loc, body)
273 }
274 notACard(t, body)
275 if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "A host still routes this name") {
276 t.Fatalf("no routed refusal flash:\n%s", page)
277 }
278 if has, err := fx.st.HasAppIn(OpsAccountID, "unwired"); err != nil || has {
279 t.Fatalf("HasAppIn(unwired) = %v %v, want no rename", has, err)
280 }
281}
282
283// Renaming is the owner's. A member meets the byte-identical 404 — before any
284// card could concede the action exists.
285func TestRenameHandlerIsOwnerOnly(t *testing.T) {
286 fx := newConsoleFixture(t)
287 freshApp(t, fx, "sprout")
288 if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
289 t.Fatal(err)
290 }
291 fx.setUser("sam@keymail.dev")
292 c := newBrowser(t)
293 signUp(t, fx, c, "code-sam-rename")
294 csrf := sessionToken(t, fx, c)
295
296 status, _, memberBody := postForm(t, c, fx.u("/apps/sprout/rename"),
297 url.Values{"csrf": {csrf}, "to": {"sapling"}})
298 if status != http.StatusNotFound {
299 t.Fatalf("member renaming = %d, want 404", status)
300 }
301 notACard(t, memberBody)
302 strangerBody := strangerRefusal(t, fx, "code-zoe-rename", fx.u("/apps/sprout/rename"),
303 url.Values{"to": {"sapling"}})
304 if memberBody != strangerBody {
305 t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody)
306 }
307 if has, err := fx.st.HasAppIn(OpsAccountID, "sapling"); err != nil || has {
308 t.Fatalf("HasAppIn(sapling) = %v %v, want no rename", has, err)
309 }
310}
311
312// CSRF is checked before ownership and before anything is read — a cross-site
313// POST is refused with 403 and never reaches the store.
314func TestRenameHandlerNeedsCSRF(t *testing.T) {
315 fx := newConsoleFixture(t)
316 freshApp(t, fx, "sprout")
317 c := signInPaul(t, fx)
318
319 status, _, body := postForm(t, c, fx.u("/apps/sprout/rename"),
320 url.Values{"csrf": {"not-the-token"}, "to": {"sapling"}, "confirmed": {"1"}})
321 if status != http.StatusForbidden {
322 t.Fatalf("rename with a bad csrf = %d, want 403:\n%s", status, body)
323 }
324 if !strings.Contains(body, "Cross-site request blocked.") {
325 t.Fatalf("403 body does not name the reason:\n%s", body)
326 }
327 if has, err := fx.st.HasAppIn(OpsAccountID, "sapling"); err != nil || has {
328 t.Fatalf("HasAppIn(sapling) = %v %v, want no rename", has, err)
329 }
330}
331
332// ---- handleAppDelete --------------------------------------------------------
333
334// appTombstone reads the bucket's deletion marker — the thing that actually
335// makes a host stop serving. The console row alone is not a deletion.
336func appTombstone(t *testing.T, fx *consoleFixture, app string) *release.AppTombstone {
337 t.Helper()
338 tomb, err := release.ReadAppTombstone(context.Background(), fx.rel, fx.sqid, app)
339 if err != nil {
340 t.Fatal(err)
341 }
342 return tomb
343}
344
345// Typing the name is a "did you mean this app?" control, checked BEFORE the
346// passkey so a slip does not cost a passkey touch — and, more to the point,
347// does not delete anything.
348func TestAppDeleteRequiresTheExactName(t *testing.T) {
349 fx := newConsoleFixture(t)
350 c := signInPaul(t, fx)
351 csrf := sessionToken(t, fx, c)
352
353 status, loc, body := postForm(t, c, fx.u("/apps/kass/delete"),
354 url.Values{"csrf": {csrf}, "confirm": {"kas"}})
355 if status != http.StatusSeeOther || strings.HasPrefix(loc, fx.fk.srv.URL) {
356 t.Fatalf("mistyped delete = %d -> %q, want a flash and no passkey demand:\n%s", status, loc, body)
357 }
358 if loc != fx.path("/apps/kass/settings/delete") {
359 t.Fatalf("mistyped delete redirected to %q, want the Settings tab", loc)
360 }
361 // The apostrophe in "app's" is HTML-escaped on the way out, so the
362 // assertion takes the unambiguous tail of the sentence.
363 if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "name exactly to delete it.") {
364 t.Fatalf("no mistyped-name flash:\n%s", page)
365 }
366 if tomb := appTombstone(t, fx, "kass"); tomb != nil {
367 t.Fatalf("a mistyped delete wrote a trash marker: %+v", tomb)
368 }
369 if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
370 t.Fatalf("HasAppIn = %v %v, want the row live", has, err)
371 }
372 var stashed int
373 fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&stashed)
374 if stashed != 0 {
375 t.Fatal("a mistyped delete parked a pending action — the passkey was asked for anyway")
376 }
377}
378
379// The whole ceremony: the name, then the passkey, then the marker and the row,
380// in that order, landing on the account home.
381func TestAppDeleteTrashesBehindThePasskey(t *testing.T) {
382 fx := newConsoleFixture(t)
383 c := signInPaul(t, fx)
384 csrf := sessionToken(t, fx, c)
385 form := url.Values{"csrf": {csrf}, "confirm": {"kass"}}
386
387 // Without the passkey, nothing moves. (postForm, not postGated: the
388 // un-stepped-up post is what proves the gate is there at all.)
389 status, loc, _ := postForm(t, c, fx.u("/apps/kass/delete"), form)
390 if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
391 t.Fatalf("un-stepped-up delete = %d -> %q, want a redirect into Keymail", status, loc)
392 }
393 if tomb := appTombstone(t, fx, "kass"); tomb != nil {
394 t.Fatalf("a trash marker was written before the passkey confirmation: %+v", tomb)
395 }
396
397 status, loc, body := completeStepUp(t, fx, c, loc)
398 if status != http.StatusSeeOther || loc != fx.path("/") {
399 t.Fatalf("resumed delete = %d -> %q, want 303 to the account home:\n%s", status, loc, body)
400 }
401 tomb := appTombstone(t, fx, "kass")
402 if tomb == nil {
403 t.Fatal("no trash marker in the bucket — hosts would go on serving the app")
404 }
405 if tomb.Actor != "paul@keymail.dev" {
406 t.Fatalf("marker actor = %q, want the owner who deleted it", tomb.Actor)
407 }
408 if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || has {
409 t.Fatalf("HasAppIn = %v %v, want the live row gone", has, err)
410 }
411 if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has {
412 t.Fatalf("HasTrashedAppIn = %v %v, want the row in the trash", has, err)
413 }
414 auditedBy(t, fx, "kass", "trash", "paul@keymail.dev", "moved kass to the trash")
415}
416
417// Production outranks deletion, all the way through the ceremony: the flag is
418// read after the passkey, and refuses even a fully confirmed owner.
419func TestAppDeleteRefusesAProductionApp(t *testing.T) {
420 fx := newConsoleFixture(t)
421 if err := release.SetProduction(context.Background(), fx.rel, fx.priv,
422 release.OpsAccount, "kass", true, "test"); err != nil {
423 t.Fatal(err)
424 }
425 c := signInPaul(t, fx)
426 csrf := sessionToken(t, fx, c)
427
428 status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/delete"),
429 url.Values{"csrf": {csrf}, "confirm": {"kass"}})
430 if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/delete") {
431 t.Fatalf("delete of a production app = %d -> %q:\n%s", status, loc, body)
432 }
433 if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "is marked as production, so deleting is refused") {
434 t.Fatalf("no production refusal flash:\n%s", page)
435 }
436 if tomb := appTombstone(t, fx, "kass"); tomb != nil {
437 t.Fatalf("a production app got a trash marker anyway: %+v", tomb)
438 }
439 if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
440 t.Fatalf("HasAppIn = %v %v, want the row live", has, err)
441 }
442}
443
444// Deleting is the owner's, and the refusal is the byte-identical 404 — a
445// member must not learn that the door exists.
446func TestAppDeleteIsOwnerOnly(t *testing.T) {
447 fx := newConsoleFixture(t)
448 if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
449 t.Fatal(err)
450 }
451 fx.setUser("sam@keymail.dev")
452 c := newBrowser(t)
453 signUp(t, fx, c, "code-sam-appdelete")
454 csrf := sessionToken(t, fx, c)
455
456 status, _, memberBody := postForm(t, c, fx.u("/apps/kass/delete"),
457 url.Values{"csrf": {csrf}, "confirm": {"kass"}})
458 if status != http.StatusNotFound {
459 t.Fatalf("member deleting an app = %d, want 404", status)
460 }
461 strangerBody := strangerRefusal(t, fx, "code-zoe-appdelete", fx.u("/apps/kass/delete"),
462 url.Values{"confirm": {"kass"}})
463 if memberBody != strangerBody {
464 t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody)
465 }
466 if tomb := appTombstone(t, fx, "kass"); tomb != nil {
467 t.Fatalf("a member's refused delete wrote a marker: %+v", tomb)
468 }
469 if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
470 t.Fatalf("HasAppIn = %v %v, want the row live", has, err)
471 }
472}
473
474// ---- handleAppMoveAccount ---------------------------------------------------
475
476// stampKassRoutes re-writes the fixture's kass routes with a stamped account,
477// which is what `carlos accounts migrate` leaves behind: the routes name the
478// new account, the console record still names the old one. That disagreement
479// IS the state this handler repairs — and the target comes from the routes,
480// never the form.
481func stampKassRoutes(t *testing.T, fx *consoleFixture, acct string) {
482 t.Helper()
483 fx.addRoute(t, registry.Route{Host: "kass.carlosapp.dev", Kind: "instance", Status: "ready",
484 Version: "v1", App: "kass", Channel: "stable", Account: acct, Addr: "127.0.0.1:9001"})
485 fx.addRoute(t, registry.Route{Host: "docs.kass.carlosapp.dev", Kind: "static", Status: "ready",
486 Version: "v1", App: "kass", Account: acct, Hibernate: true})
487}
488
489// The repair: card, passkey, and the record lands in the account the routes
490// already deploy from — with the arrival recorded on BOTH accounts, since
491// neither activity trail alone tells the whole story afterwards.
492func TestAppMoveConfirmsThenMovesTheRecord(t *testing.T) {
493 fx := newConsoleFixture(t)
494 const destID = 4242
495 if err := fx.st.CreateAccount(destID, "cdef", "Second", "paul@keymail.dev", time.Now()); err != nil {
496 t.Fatal(err)
497 }
498 stampKassRoutes(t, fx, "cdef")
499 c := signInPaul(t, fx)
500 csrf := sessionToken(t, fx, c)
501 form := url.Values{"csrf": {csrf}}
502
503 status, loc, body := postForm(t, c, fx.u("/apps/kass/move"), form)
504 if strings.HasPrefix(loc, fx.fk.srv.URL) {
505 t.Fatalf("the owner met the passkey before the card (-> %q)", loc)
506 }
507 card := confirmCard(t, status, body, "Move kass to Second?", csrf)
508 if !strings.Contains(card, "Move to Second") {
509 t.Fatalf("the card's button does not name the destination:\n%s", card)
510 }
511 if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
512 t.Fatalf("HasAppIn(ops) = %v %v after the card, want the record unmoved", has, err)
513 }
514
515 form.Set("confirmed", "1")
516 status, loc, _ = postForm(t, c, fx.u("/apps/kass/move"), form)
517 if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
518 t.Fatalf("confirmed move = %d -> %q, want a redirect into Keymail", status, loc)
519 }
520 if has, err := fx.st.HasAppIn(destID, "kass"); err != nil || has {
521 t.Fatalf("HasAppIn(dest) = %v %v before the passkey, want nothing moved", has, err)
522 }
523
524 status, loc, body = completeStepUp(t, fx, c, loc)
525 if status != http.StatusSeeOther || loc != "/a/cdef/apps/kass" {
526 t.Fatalf("resumed move = %d -> %q, want 303 to the app under its new account:\n%s", status, loc, body)
527 }
528 if has, err := fx.st.HasAppIn(destID, "kass"); err != nil || !has {
529 t.Fatalf("HasAppIn(dest) = %v %v, want the record in the destination account", has, err)
530 }
531 if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || has {
532 t.Fatalf("HasAppIn(ops) = %v %v, want the record gone from the source", has, err)
533 }
534 // Both trails: the source records the departure, the destination the arrival.
535 auditedBy(t, fx, "kass", "move", "paul@keymail.dev", "moved kass to account Second (cdef)")
536 arrived, err := fx.st.Events(destID, "kass", 10)
537 if err != nil {
538 t.Fatal(err)
539 }
540 var found bool
541 for _, e := range arrived {
542 if e.Action == "move" && strings.Contains(e.Detail, "arrived from account") {
543 found = true
544 }
545 }
546 if !found {
547 t.Fatalf("destination events = %+v, want the arrival recorded there too", arrived)
548 }
549}
550
551// Moving the record into an account the viewer does not belong to would strand
552// the app for them. The refusal must not disclose that account as a move target.
553func TestAppMoveRefusesANonMemberOfTheTarget(t *testing.T) {
554 fx := newConsoleFixture(t)
555 const destID = 4343
556 if err := fx.st.CreateAccount(destID, "cghi", "Third", "kim@keymail.dev", time.Now()); err != nil {
557 t.Fatal(err)
558 }
559 stampKassRoutes(t, fx, "cghi")
560 c := signInPaul(t, fx)
561 csrf := sessionToken(t, fx, c)
562
563 status, _, body := postGated(t, fx, c, fx.u("/apps/kass/move"),
564 url.Values{"csrf": {csrf}, "confirmed": {"1"}})
565 ghostStatus, _, ghostBody := get(t, c, fx.ts.URL+"/definitely-not-a-page")
566 if status != http.StatusNotFound || status != ghostStatus || body != ghostBody {
567 t.Fatalf("move into a stranger account = %d %q; unrouted = %d %q", status, body, ghostStatus, ghostBody)
568 }
569 notACard(t, body)
570 if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
571 t.Fatalf("HasAppIn(ops) = %v %v, want the record where it was", has, err)
572 }
573 if has, err := fx.st.HasAppIn(destID, "kass"); err != nil || has {
574 t.Fatalf("HasAppIn(dest) = %v %v, want nothing moved", has, err)
575 }
576}
577
578// With no disagreement there is nothing to repair — and no card, because the
579// handler has already decided.
580func TestAppMoveRefusesWhenRoutesAgree(t *testing.T) {
581 fx := newConsoleFixture(t)
582 // The fixture's routes carry no stamped account, which reads as the ops
583 // account — the very account this page belongs to.
584 c := signInPaul(t, fx)
585 csrf := sessionToken(t, fx, c)
586
587 status, loc, body := postForm(t, c, fx.u("/apps/kass/move"), url.Values{"csrf": {csrf}})
588 if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
589 t.Fatalf("move with agreeing routes = %d -> %q:\n%s", status, loc, body)
590 }
591 notACard(t, body)
592 if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page,
593 "there is nothing to move") {
594 t.Fatalf("no nothing-to-move flash:\n%s", page)
595 }
596 if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
597 t.Fatalf("HasAppIn = %v %v, want the record untouched", has, err)
598 }
599}
600
601// A PARTIAL mismatch is not a move. The Settings card is only offered when
602// every non-platform route disagrees (AllMismatched), but a direct POST — or a
603// tab left open across a re-stamp — reaches the handler regardless, and the
604// target it derives is only the FIRST route's account. Here that first route
605// (docs.* sorts before kass.*, and List orders by host) is the ONLY one
606// stamped elsewhere: acting on it would move the whole record to where a
607// minority of the app's hosts deploy from, converting a partial disagreement
608// into a total one the other way. The routes are the repair, so the handler
609// says so — before the card, and before anything is written.
610func TestAppMoveRefusesAPartialMismatch(t *testing.T) {
611 fx := newConsoleFixture(t)
612 const destID = 4444
613 // A destination that would otherwise pass every later check — known to the
614 // console, and one paul belongs to — so the refusal can only be the
615 // disagreement itself.
616 if err := fx.st.CreateAccount(destID, "cdef", "Second", "paul@keymail.dev", time.Now()); err != nil {
617 t.Fatal(err)
618 }
619 if err := fx.st.AddMember(destID, "paul@keymail.dev", RoleOwner, time.Now()); err != nil {
620 t.Fatal(err)
621 }
622 // One of kass's two routes re-stamped, and it is the one that sorts first.
623 fx.addRoute(t, registry.Route{Host: "docs.kass.carlosapp.dev", Kind: "static", Status: "ready",
624 Version: "v1", App: "kass", Account: "cdef", Hibernate: true})
625 c := signInPaul(t, fx)
626 csrf := sessionToken(t, fx, c)
627
628 // Unconfirmed: no card, because a card here would claim the app's hosts
629 // already read from Second when only one of them does.
630 status, loc, body := postForm(t, c, fx.u("/apps/kass/move"), url.Values{"csrf": {csrf}})
631 if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
632 t.Fatalf("partial-mismatch move = %d -> %q:\n%s", status, loc, body)
633 }
634 notACard(t, body)
635 if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page,
636 "routes disagree among themselves") {
637 t.Fatalf("no routes-disagree refusal flash:\n%s", page)
638 }
639
640 // Confirmed, straight at the door: same refusal, and nothing moved.
641 status, loc, body = postGated(t, fx, c, fx.u("/apps/kass/move"),
642 url.Values{"csrf": {csrf}, "confirmed": {"1"}, "to": {"cdef"}})
643 if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
644 t.Fatalf("confirmed partial-mismatch move = %d -> %q:\n%s", status, loc, body)
645 }
646 if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page,
647 "routes disagree among themselves") {
648 t.Fatalf("no routes-disagree refusal flash on the confirmed POST:\n%s", page)
649 }
650 if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
651 t.Fatalf("HasAppIn(ops) = %v %v, want the record where it was", has, err)
652 }
653 if has, err := fx.st.HasAppIn(destID, "kass"); err != nil || has {
654 t.Fatalf("HasAppIn(dest) = %v %v, want nothing moved to the minority account", has, err)
655 }
656}
657
658// ---- the Settings page ------------------------------------------------------
659
660// The rename form is offered while the name is still only a record here, and
661// replaced by the reason the moment the bucket knows it.
662func TestSettingsPageRenameFormFollowsTheBucket(t *testing.T) {
663 fx := newConsoleFixture(t)
664 freshApp(t, fx, "sprout")
665 c := signInPaul(t, fx)
666
667 status, _, body := get(t, c, fx.u("/apps/sprout/settings"))
668 if status != http.StatusOK {
669 t.Fatalf("settings page = %d:\n%s", status, body)
670 }
671 if !strings.Contains(body, `id="rename-to"`) ||
672 !strings.Contains(body, `action="/a/bab/apps/sprout/rename"`) {
673 t.Fatalf("a never-shipped app is offered no rename form:\n%s", body)
674 }
675
676 shipSomething(t, fx, "sprout")
677 _, _, body = get(t, c, fx.u("/apps/sprout/settings"))
678 if strings.Contains(body, `id="rename-to"`) {
679 t.Fatalf("the rename form survived the app's first ship:\n%s", body)
680 }
681 if !strings.Contains(body, "Renaming: This app has shipped") {
682 t.Fatalf("the page does not say why renaming is off:\n%s", body)
683 }
684}
685
686// Everything on this page that changes the app's identity is the owner's. A
687// member sees the identity and the domains, and neither form.
688func TestSettingsPageHidesOwnerFormsFromMembers(t *testing.T) {
689 fx := newConsoleFixture(t)
690 freshApp(t, fx, "sprout")
691 // The owner's own view first, so the member-side absences below cannot
692 // pass vacuously: both forms ARE eligible to render in this exact state.
693 owner := signInPaul(t, fx)
694 _, _, obody := get(t, owner, fx.u("/apps/sprout/settings"))
695 _, _, odelete := get(t, owner, fx.u("/apps/sprout/settings/delete"))
696 if !strings.Contains(obody, `id="rename-to"`) || !strings.Contains(odelete, `id="delete-confirm"`) {
697 t.Fatalf("the owner is not offered both forms — the member checks below would be vacuous:\n%s", obody)
698 }
699
700 if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
701 t.Fatal(err)
702 }
703 fx.setUser("sam@keymail.dev")
704 cm := newBrowser(t)
705 signUp(t, fx, cm, "code-sam-settings")
706 status, _, body := get(t, cm, fx.u("/apps/sprout/settings"))
707 if status != http.StatusOK {
708 t.Fatalf("member settings page = %d, want 200:\n%s", status, body)
709 }
710 _, _, domainsBody := get(t, cm, fx.u("/apps/sprout/settings/domains"))
711 if !strings.Contains(domainsBody, "Domains &amp; TLS") {
712 t.Fatalf("a member cannot see the page at all:\n%s", body)
713 }
714 if strings.Contains(body, `id="rename-to"`) || strings.Contains(body, "/rename\"") {
715 t.Errorf("a member is offered the rename form:\n%s", body)
716 }
717 _, _, deleteBody := get(t, cm, fx.u("/apps/sprout/settings/delete"))
718 if strings.Contains(deleteBody, `id="delete-confirm"`) || strings.Contains(deleteBody, `action="/a/bab/apps/sprout/delete"`) {
719 t.Errorf("a member is offered the delete form:\n%s", deleteBody)
720 }
721}
722
723// ---- the store card and its destroy door ------------------------------------
724//
725// Destroying a store is the only console action that destroys a customer's
726// OBJECTS outside the app trash, and there is no trash for a bucket. So the
727// ceremony is the purge door's, with one difference that matters: what a person
728// types is the BUCKET name, because the bucket is the thing that stops
729// existing — an app name typed here would confirm the wrong noun.
730
731// pendingKey puts the record into the half-finished rotation state: two keys
732// open the bucket until the member finishes it, and the page must nag.
733func pendingKey(t *testing.T, fx *consoleFixture, app, prev string) {
734 t.Helper()
735 ctx := context.Background()
736 rec, etag, err := objstore.Get(ctx, fx.rel, fx.sqid, app)
737 if err != nil || rec == nil || rec.Provisioned == nil {
738 t.Fatalf("read record: %+v %v", rec, err)
739 }
740 rec.Provisioned.PrevKeyID = prev
741 if _, err := objstore.Put(ctx, fx.rel, fx.sqid, app, rec, etag); err != nil {
742 t.Fatal(err)
743 }
744}
745
746// stashedActions counts parked step-up actions — the evidence that a passkey
747// was demanded. A refusal that gets this far has already cost a gesture.
748func stashedActions(t *testing.T, fx *consoleFixture) int {
749 t.Helper()
750 var n int
751 if err := fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&n); err != nil {
752 t.Fatal(err)
753 }
754 return n
755}
756
757// The typed word is the bucket, checked BEFORE the passkey: a slip must cost a
758// retry, never a gesture, and must destroy nothing. The app's own name is the
759// most likely wrong answer, so it is the one under test.
760func TestStoreDestroyRefusesAnythingButTheBucketName(t *testing.T) {
761 fx := newConsoleFixture(t)
762 fake := armStoreBackend(t, fx)
763 bucket := seedStore(t, fx, fake, "kass", false)
764 c := signInPaul(t, fx)
765 csrf := sessionToken(t, fx, c)
766
767 for _, typed := range []string{"kass", "", "carlos-bab-kas"} {
768 status, loc, body := postForm(t, c, fx.u("/apps/kass/store/destroy"),
769 url.Values{"csrf": {csrf}, "confirm": {typed}})
770 if strings.HasPrefix(loc, fx.fk.srv.URL) {
771 t.Fatalf("confirm %q demanded a passkey before it was even checked (-> %q)", typed, loc)
772 }
773 if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") {
774 t.Fatalf("confirm %q = %d -> %q, want the settings flash:\n%s", typed, status, loc, body)
775 }
776 if n := stashedActions(t, fx); n != 0 {
777 t.Fatalf("confirm %q parked %d pending actions — the passkey was asked for anyway", typed, n)
778 }
779 estateStanding(t, fx, fake, "kass", bucket, "a mistyped destroy")
780 if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil {
781 t.Fatalf("a mistyped destroy touched the record: %+v", rec)
782 }
783 }
784 if _, _, page := get(t, c, fx.u("/apps/kass/settings/storage")); !strings.Contains(page, bucket) {
785 t.Fatalf("the refusal does not tell the owner what to type:\n%s", page)
786 }
787}
788
789// The whole ceremony: the bucket typed, the passkey, and then everything —
790// objects, bucket, principal, key and the record — is gone. The serial moves
791// because the delivered variables were unset: every box serving this app is
792// running with credentials that no longer work, and must converge.
793func TestStoreDestroyDestroysTheEstateAndAnnounces(t *testing.T) {
794 fx := newConsoleFixture(t)
795 fake := armStoreBackend(t, fx)
796 bucket := seedStore(t, fx, fake, "kass", false)
797 c := signInPaul(t, fx)
798 csrf := sessionToken(t, fx, c)
799 before := serial(t, fx)
800
801 // Without the passkey, nothing moves.
802 status, loc, _ := postForm(t, c, fx.u("/apps/kass/store/destroy"),
803 url.Values{"csrf": {csrf}, "confirm": {bucket}})
804 if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
805 t.Fatalf("un-stepped-up destroy = %d -> %q, want a redirect into Keymail", status, loc)
806 }
807 estateStanding(t, fx, fake, "kass", bucket, "a destroy awaiting its passkey")
808
809 status, loc, body := completeStepUp(t, fx, c, loc)
810 if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") {
811 t.Fatalf("resumed destroy = %d -> %q:\n%s", status, loc, body)
812 }
813 estateGone(t, fake, "a completed destroy")
814 if rec := storedRecord(t, fx, "kass"); rec != nil {
815 t.Fatalf("the store record survived its own destruction: %+v", rec)
816 }
817 if got := serial(t, fx); got <= before {
818 t.Fatalf("serial after a destroy = %d, want a bump from %d — the boxes must converge on the unset variables", got, before)
819 }
820 auditedBy(t, fx, "kass", "store-destroy", "paul@keymail.dev", bucket)
821 if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, bucket) {
822 t.Fatalf("the destroy flash does not name what was destroyed:\n%s", page)
823 }
824}
825
826// TestStoreDestroyLeavesTheEmailEstateAlone: R3, review follow-up. A
827// previous fix wave wired mail.Teardown into THIS door — but this door's
828// confirm word is the bucket name, its button says "Destroy store", and its
829// success flash (asserted above) names only the object store. Nothing here
830// ever told the owner that confirming would also destroy the SES identity,
831// the IAM principal, delivered credentials, published DNS and — globally,
832// irreversibly — the domain claim. Consent for the store is not consent for
833// email; ownership.go's handlePurgeApp (the whole-app delete) is the door
834// that destroys both, and says so.
835//
836// The proof is the claim's own semantics (mirroring
837// TestPurgeReleasesTheDomainClaim in deletion_test.go), not an internal peek
838// at bucket keys: a SECOND, unrelated account trying to claim the SAME
839// domain is refused with ErrDomainClaimed both before and after the store
840// destroy — if this door released it, the second account's EnsureDomain
841// would start succeeding.
842func TestStoreDestroyLeavesTheEmailEstateAlone(t *testing.T) {
843 fx := newConsoleFixture(t)
844 fake := armStoreBackend(t, fx)
845 bucket := seedStore(t, fx, fake, "kass", false)
846
847 mfake := mail.NewMemProvisioner("1")
848 writer := dnsrec.NewMemWriter()
849 fx.srv.cfg.MailProvisioner = mfake
850 fx.srv.cfg.DNSWriter = writer
851 dep := mail.Deployment{Region: "eu-west-1"} // no PlatformSuffix, matching
852 // TestPurgeReleasesTheDomainClaim: keeps this about the claim gate
853 // alone, not the separate platform-name-ownership gate.
854 ctx := context.Background()
855 now := time.Now()
856 domain := "store-destroy-claim.example.com"
857 // Deliberately no mail.Declare/Enable — kass has a domain record (and
858 // the claim it holds) but no Provisioned credential, exactly the shape
859 // `carlos email domains add` alone leaves. If this door's old
860 // mail.Teardown wiring were still here, it would find and release this
861 // claim too — mail.Teardown traces the domain-referrer set, not just a
862 // delivered credential.
863 if _, _, err := mail.EnsureDomain(ctx, fx.rel, mfake, writer, dep, fx.sqid, "kass", domain, now); err != nil {
864 t.Fatalf("EnsureDomain for kass: %v", err)
865 }
866 if _, _, err := mail.EnsureDomain(ctx, fx.rel, mfake, writer, dep, "zzzz", "other", domain, now); !errors.Is(err, mail.ErrDomainClaimed) {
867 t.Fatalf("a second account's EnsureDomain before the destroy = %v, want ErrDomainClaimed", err)
868 }
869
870 c := signInPaul(t, fx)
871 csrf := sessionToken(t, fx, c)
872 status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/store/destroy"),
873 url.Values{"csrf": {csrf}, "confirm": {bucket}})
874 if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") {
875 t.Fatalf("store destroy = %d -> %q:\n%s", status, loc, body)
876 }
877 estateGone(t, fake, "a store destroy that must leave email alone")
878 if rec := storedRecord(t, fx, "kass"); rec != nil {
879 t.Fatalf("the store record survived its own destruction: %+v", rec)
880 }
881
882 if _, _, err := mail.EnsureDomain(ctx, fx.rel, mfake, writer, dep, "zzzz", "other", domain, now); !errors.Is(err, mail.ErrDomainClaimed) {
883 t.Fatalf("the domain claim was released by a store destroy: err = %v, want it still ErrDomainClaimed", err)
884 }
885}
886
887// A declaration is a request nobody has acted on: no bucket, no key, nothing to
888// spend backend authority on. So a console with no backend at all may still
889// withdraw it — and the word to type is the APP's name, because that is the
890// only noun this record has.
891func TestStoreDestroyOfADeclarationNeedsNoBackend(t *testing.T) {
892 fx := newConsoleFixture(t)
893 putRecord(t, fx, "kass", &objstore.Record{
894 Declared: &objstore.Declared{EnvPrefix: objstore.DefaultEnvPrefix, At: time.Now(), By: "paul@keymail.dev"},
895 })
896 fx.srv.cfg.StoreProvisioner = nil
897 c := signInPaul(t, fx)
898 csrf := sessionToken(t, fx, c)
899
900 status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/store/destroy"),
901 url.Values{"csrf": {csrf}, "confirm": {"kass"}})
902 if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") {
903 t.Fatalf("destroy of a declaration = %d -> %q:\n%s", status, loc, body)
904 }
905 if rec := storedRecord(t, fx, "kass"); rec != nil {
906 t.Fatalf("the declaration survived: %+v", rec)
907 }
908 auditedBy(t, fx, "kass", "store-destroy", "paul@keymail.dev", "declaration")
909}
910
911// A provisioned store on a backend-less console is the other half of that rule:
912// the bucket and the key are real and this console has no authority over them,
913// so it refuses and names the variable that would give it some — before the
914// passkey, since no gesture could change the answer.
915func TestStoreDestroyRefusesAnEstateWithNoBackend(t *testing.T) {
916 fx := newConsoleFixture(t)
917 fake := armStoreBackend(t, fx)
918 bucket := seedStore(t, fx, fake, "kass", false)
919 fx.srv.cfg.StoreProvisioner = nil
920 c := signInPaul(t, fx)
921 csrf := sessionToken(t, fx, c)
922
923 status, loc, body := postForm(t, c, fx.u("/apps/kass/store/destroy"),
924 url.Values{"csrf": {csrf}, "confirm": {bucket}})
925 if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") {
926 t.Fatalf("backend-less destroy = %d -> %q:\n%s", status, loc, body)
927 }
928 if n := stashedActions(t, fx); n != 0 {
929 t.Fatalf("a refusal nothing could change still cost a passkey (%d parked actions)", n)
930 }
931 estateStanding(t, fx, fake, "kass", bucket, "a backend-less destroy")
932 if rec := storedRecord(t, fx, "kass"); rec == nil {
933 t.Fatal("the record was deleted with the estate still standing")
934 }
935 if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "CARLOS_STORE_BACKEND") {
936 t.Fatalf("the refusal does not name the backend variable:\n%s", page)
937 }
938}
939
940// A backend that refuses mid-walk must leave the record naming the estate: the
941// record is the ONLY thing that names a bucket and a principal, so a door that
942// deleted it on a failed teardown would leave a paid, credentialed estate
943// nobody can find. The person is told it is still there, which is also the
944// instruction — re-entering the door resumes.
945func TestStoreDestroyFailureLeavesTheRecordNamingTheEstate(t *testing.T) {
946 fx := newConsoleFixture(t)
947 fake := armStoreBackend(t, fx)
948 bucket := seedStore(t, fx, fake, "kass", false)
949 fake.FailNext["EmptyBucket"] = errors.New("backend unreachable")
950 c := signInPaul(t, fx)
951 csrf := sessionToken(t, fx, c)
952
953 status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/store/destroy"),
954 url.Values{"csrf": {csrf}, "confirm": {bucket}})
955 if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") {
956 t.Fatalf("failed destroy = %d -> %q:\n%s", status, loc, body)
957 }
958 estateStanding(t, fx, fake, "kass", bucket, "a failed destroy")
959 if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil {
960 t.Fatalf("a failed destroy deleted the record naming the estate: %+v", rec)
961 }
962 if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "still there") {
963 t.Fatalf("the failure does not say the store survived:\n%s", page)
964 }
965}
966
967// Destroying is the account owner's. A member meets the same 404 a stranger
968// does — before any passkey redirect could concede the app, or the store,
969// exists.
970func TestStoreDestroyIsOwnerOnly(t *testing.T) {
971 fx := newConsoleFixture(t)
972 fake := armStoreBackend(t, fx)
973 bucket := seedStore(t, fx, fake, "kass", false)
974 if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
975 t.Fatal(err)
976 }
977 fx.setUser("sam@keymail.dev")
978 c := newBrowser(t)
979 signUp(t, fx, c, "code-sam-store-destroy")
980 csrf := sessionToken(t, fx, c)
981
982 status, _, memberBody := postForm(t, c, fx.u("/apps/kass/store/destroy"),
983 url.Values{"csrf": {csrf}, "confirm": {bucket}})
984 if status != http.StatusNotFound {
985 t.Fatalf("member destroying a store = %d, want 404", status)
986 }
987 strangerBody := strangerRefusal(t, fx, "code-zoe-store-destroy",
988 fx.u("/apps/kass/store/destroy"), url.Values{"confirm": {bucket}})
989 if memberBody != strangerBody {
990 t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody)
991 }
992 estateStanding(t, fx, fake, "kass", bucket, "a member's refused destroy")
993}
994
995// The card is the member's only sight of a store they cannot reach with box
996// commands: what prefix it delivers under, which bucket, which key and how old
997// that key is — labelled as the KEY's age, because a grant repair moves that
998// timestamp and calling it "provisioned" would be a lie the page tells forever.
999func TestSettingsPageShowsTheStoreCard(t *testing.T) {
1000 fx := newConsoleFixture(t)
1001 fake := armStoreBackend(t, fx)
1002 bucket := seedStore(t, fx, fake, "kass", false)
1003 pendingKey(t, fx, "kass", "MEMKEY-OLD")
1004 keyID := storedRecord(t, fx, "kass").Provisioned.KeyID
1005 c := signInPaul(t, fx)
1006
1007 status, _, body := get(t, c, fx.u("/apps/kass/settings/storage"))
1008 if status != http.StatusOK {
1009 t.Fatalf("settings page = %d:\n%s", status, body)
1010 }
1011 for _, want := range []string{
1012 objstore.DefaultEnvPrefix, // the env set the app reads
1013 bucket, // the bucket its data is in
1014 keyID, // the key that opens it
1015 "last credential change", // what the age actually means
1016 "carlos store rotate --finish --app kass", // the half-done rotation nag
1017 `id="store-destroy-confirm"`, // the destroy form
1018 } {
1019 if !strings.Contains(body, want) {
1020 t.Fatalf("the store card is missing %q:\n%s", want, body)
1021 }
1022 }
1023 if strings.Contains(body, "Provisioned</th>") {
1024 t.Fatalf("the card labels the key timestamp as a provisioning date:\n%s", body)
1025 }
1026
1027 // An app nobody has declared a store for gets the one line that tells them
1028 // how to ask for one — and no destroy form for a store that isn't there.
1029 freshApp(t, fx, "sprout")
1030 _, _, body = get(t, c, fx.u("/apps/sprout/settings/storage"))
1031 if !strings.Contains(body, "carlos store create") {
1032 t.Fatalf("an undeclared app is not told how to ask for a store:\n%s", body)
1033 }
1034 if strings.Contains(body, `id="store-destroy-confirm"`) {
1035 t.Fatalf("an app with no store is offered a destroy form:\n%s", body)
1036 }
1037}
1038
1039// ---- the domain attach/detach section of Settings (#82 Task 5) -------------
1040//
1041// The honesty text ("attached where the app runs — on its box, by an
1042// operator") is retired now that this account's own door does the attaching
1043// (appdomains.go, Task 4): the page must offer that door's form instead of a
1044// box command. And a claim this app holds must show up here the moment it
1045// exists, not only once a registry route has caught up with it — which,
1046// for an ALIAS claim, is never: domainattach.go's own comment is that
1047// minting a route row for an alias is exactly the #129/#148 bare-alias
1048// class this design avoids.
1049
1050// TestSettingsPageOffersAttachFormNotBoxInstructions is the honesty-text
1051// retirement itself.
1052func TestSettingsPageOffersAttachFormNotBoxInstructions(t *testing.T) {
1053 fx := newConsoleFixture(t)
1054 freshApp(t, fx, "sprout")
1055 c := signInPaul(t, fx)
1056
1057 status, _, body := get(t, c, fx.u("/apps/sprout/settings/domains"))
1058 if status != http.StatusOK {
1059 t.Fatalf("settings page = %d:\n%s", status, body)
1060 }
1061 if strings.Contains(body, "carlos add --host") {
1062 t.Fatalf("the settings page still tells the owner to attach on the box:\n%s", body)
1063 }
1064 if !strings.Contains(body, `action="/a/bab/apps/sprout/domains/attach"`) {
1065 t.Fatalf("the settings page does not post to its own attach door:\n%s", body)
1066 }
1067}
1068
1069// TestSettingsPageShowsAnUnroutedClaimAsAttaching exercises the gap the
1070// honesty text used to paper over: kass already has a ready route, so
1071// attaching docs.example.com to it takes the ALIAS branch, which never gets
1072// a registry row — the claim must still render, as its own placeholder row,
1073// not vanish until a route exists to join it to.
1074func TestSettingsPageShowsAnUnroutedClaimAsAttaching(t *testing.T) {
1075 const host = "docs.example.com"
1076 fx, c, _ := domainAttachedFixture(t, "kass", host)
1077
1078 status, _, body := get(t, c, fx.u("/apps/kass/settings/domains"))
1079 if status != http.StatusOK {
1080 t.Fatalf("settings page = %d:\n%s", status, body)
1081 }
1082 if !strings.Contains(body, host) {
1083 t.Fatalf("an attached claim with no route yet does not appear on its own Settings page:\n%s", body)
1084 }
1085 if !strings.Contains(body, "custom · attaching") {
1086 t.Fatalf("the claim-only row does not say it's still attaching:\n%s", body)
1087 }
1088}
1089
1090// TestSettingsPageShowsChannelFieldWhenOnlyRouteIsPlatform is the review
1091// fix: readyRouteHost (domainattach.go) skips Platform rows when deciding
1092// whether an attach can alias, so an app whose only route is its own
1093// platform mint takes the first-route branch server-side and needs the
1094// channel field exactly as a never-shipped app does — `len(Detail.Routes)
1095// == 0` would wrongly hide it, because the platform route makes that slice
1096// non-empty.
1097func TestSettingsPageShowsChannelFieldWhenOnlyRouteIsPlatform(t *testing.T) {
1098 fx := newConsoleFixture(t)
1099 freshApp(t, fx, "sprout")
1100 fx.addRoute(t, registry.Route{Host: "sprout.bab.oncarlos.com", Kind: "static", Status: "ready",
1101 App: "sprout", Channel: "stable", Platform: true})
1102 c := signInPaul(t, fx)
1103
1104 status, _, body := get(t, c, fx.u("/apps/sprout/settings/domains"))
1105 if status != http.StatusOK {
1106 t.Fatalf("settings page = %d:\n%s", status, body)
1107 }
1108 if !strings.Contains(body, `id="attach-channel"`) {
1109 t.Fatalf("an app whose only route is its platform mint is not offered the channel field:\n%s", body)
1110 }
1111}
1112
1113// TestAssembleSettingsListsRoutedDomainsWithoutABucket is the other review
1114// fix: assembleSettings used to return before sv.Domains was ever set on a
1115// bucketless console (s.cfg.Releases == nil), so a routed app's own domain
1116// would vanish from Settings.Domains the moment there was no bucket to
1117// check the never-shipped rename gate against.
1118//
1119// This calls assembleSettings directly rather than through the HTTP page:
1120// a bucketless console panics inside Assemble itself (assembleChannels,
1121// view.go, calls release.Store.List on every app-page tab, Settings
1122// included, with no nil guard) — a real, pre-existing gap, but one that
1123// reaches every app page and is not Task 5's Settings-only bug. Fixing
1124// assembleSettings's own early return is; isolating it here proves that fix
1125// without the unit test tripping over the wider, separate one.
1126func TestAssembleSettingsListsRoutedDomainsWithoutABucket(t *testing.T) {
1127 fx := newConsoleFixture(t)
1128 fx.srv.cfg.Releases = nil
1129 acct, err := fx.st.AccountBySqid(fx.sqid)
1130 if err != nil || acct == nil {
1131 t.Fatalf("fixture account %s: %v", fx.sqid, err)
1132 }
1133 detail := &AppView{
1134 App: "kass",
1135 Domains: []DomainView{{Domain: "kass.carlosapp.dev", Type: "custom"}},
1136 }
1137 p := &page{AppName: "kass", Detail: detail}
1138
1139 sv := fx.srv.assembleSettings(context.Background(), p, acct)
1140 if !sv.NoBucket || sv.StoreUnavailable || sv.MailUnavailable || sv.FeaturesUnavailable {
1141 t.Fatalf("bucketless settings flags = NoBucket %v, unavailable %v/%v/%v", sv.NoBucket,
1142 sv.StoreUnavailable, sv.MailUnavailable, sv.FeaturesUnavailable)
1143 }
1144 if len(sv.Domains) != 1 || sv.Domains[0].Domain != "kass.carlosapp.dev" {
1145 t.Fatalf("Settings.Domains on a bucketless console = %+v, want kass's routed domain still listed", sv.Domains)
1146 }
1147}
1148
1149// ---- Settings tab: the Email card (issue #240) ------------------------------
1150//
1151// mailCard is object storage's sibling (storeCard, above) — same declare ->
1152// grant -> provision -> rotate shape, same error discipline: a read failure
1153// returns nil so the page says nothing about email at all rather than
1154// guessing, and an absent record returns a zero card, the shape the
1155// empty-state line renders from. Available is the one field that does NOT
1156// come from the record at all: it is s.cfg.MailProvisioner != nil, read
1157// before the record is, because it decides which of the two empty-state
1158// sentences a zero card gets — and a deployment with no provisioner must
1159// never be told to run a command that mailProvisionerGate would 404.
1160
1161// putMailRecord seeds apps/<acct>/<app>/email.json directly — the state the
1162// card has to report on and the walk functions (Declare/ApplyGrant/Enable/
1163// SetPaused) would normally reach through their own doors.
1164func putMailRecord(t *testing.T, fx *consoleFixture, app string, rec *mail.Record) {
1165 t.Helper()
1166 if _, err := mail.Put(context.Background(), fx.rel, fx.sqid, app, rec, ""); err != nil {
1167 t.Fatalf("seed mail record for %s: %v", app, err)
1168 }
1169}
1170
1171// mailCardAccount is the fixture account as mailCard's Account parameter —
1172// only Sqid is read, so a bare literal is enough; AccountBySqid's roundtrip
1173// through the console database would prove nothing extra.
1174func mailCardAccount(fx *consoleFixture) *Account { return &Account{Sqid: fx.sqid} }
1175
1176func TestMailCardStates(t *testing.T) {
1177 now := time.Now()
1178 cases := []struct {
1179 name string
1180 app string
1181 rec *mail.Record // nil = write nothing
1182 provisionerNil bool
1183 check func(t *testing.T, card *MailCard)
1184 }{
1185 {
1186 name: "absent record",
1187 app: "mail-absent",
1188 check: func(t *testing.T, card *MailCard) {
1189 if card == nil {
1190 t.Fatal("absent record with no read error returned nil, want a zero card")
1191 }
1192 if !card.Available {
1193 t.Fatal("Available = false with a MailProvisioner configured")
1194 }
1195 if card.Declared || card.Granted || card.Provisioned || len(card.Standalone) != 0 {
1196 t.Fatalf("absent record produced a non-zero card: %+v", card)
1197 }
1198 },
1199 },
1200 {
1201 name: "declared only",
1202 app: "mail-declared",
1203 rec: &mail.Record{Declared: &mail.Declared{
1204 EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now, By: "paul@keymail.dev",
1205 }},
1206 check: func(t *testing.T, card *MailCard) {
1207 if !card.Declared || card.EnvPrefix != "CARLOS_SMTP" || card.From != "hi@example.com" {
1208 t.Fatalf("declared-only card = %+v", card)
1209 }
1210 if card.Granted || card.Provisioned {
1211 t.Fatalf("declared-only card claims more than declared: %+v", card)
1212 }
1213 },
1214 },
1215 {
1216 name: "granted",
1217 app: "mail-granted",
1218 rec: &mail.Record{
1219 Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now},
1220 Grant: &mail.GrantBlock{Domains: []string{"example.com"}, DailyCap: 500, Source: "tier:free", At: now},
1221 },
1222 check: func(t *testing.T, card *MailCard) {
1223 if !card.Granted || card.DailyCap != 500 {
1224 t.Fatalf("granted card = %+v", card)
1225 }
1226 if len(card.Domains) != 1 || card.Domains[0] != "example.com" {
1227 t.Fatalf("granted card domains = %+v, want [example.com]", card.Domains)
1228 }
1229 if card.Provisioned {
1230 t.Fatalf("granted-only card claims provisioned: %+v", card)
1231 }
1232 },
1233 },
1234 {
1235 name: "provisioned",
1236 app: "mail-provisioned",
1237 rec: &mail.Record{
1238 Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now},
1239 Grant: &mail.GrantBlock{Domains: []string{"example.com"}, DailyCap: 500, At: now},
1240 Provisioned: &mail.Provisioned{KeyID: "AKIA1", Active: true, At: now.Add(-2 * time.Hour)},
1241 },
1242 check: func(t *testing.T, card *MailCard) {
1243 if !card.Provisioned || card.KeyID != "AKIA1" {
1244 t.Fatalf("provisioned card = %+v", card)
1245 }
1246 if card.KeyAge == "" {
1247 t.Fatal("provisioned card has no KeyAge")
1248 }
1249 if card.Paused || card.PendingFinish {
1250 t.Fatalf("a healthy provisioned card reads paused or pending-finish: %+v", card)
1251 }
1252 },
1253 },
1254 {
1255 name: "mid-rotation",
1256 app: "mail-rotating",
1257 rec: &mail.Record{
1258 Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now},
1259 Provisioned: &mail.Provisioned{KeyID: "AKIA2", PrevKeyID: "AKIA1", Active: true, At: now},
1260 },
1261 check: func(t *testing.T, card *MailCard) {
1262 if !card.PendingFinish {
1263 t.Fatalf("a record with a PrevKeyID did not set PendingFinish: %+v", card)
1264 }
1265 },
1266 },
1267 {
1268 name: "paused-cap",
1269 app: "mail-paused-cap",
1270 rec: &mail.Record{
1271 Provisioned: &mail.Provisioned{KeyID: "AKIA3", Active: false, PauseKind: mail.PauseKindCap, At: now},
1272 },
1273 check: func(t *testing.T, card *MailCard) {
1274 if !card.Paused || card.PauseKind != mail.PauseKindCap {
1275 t.Fatalf("paused-cap card = %+v", card)
1276 }
1277 },
1278 },
1279 {
1280 name: "paused-rate",
1281 app: "mail-paused-rate",
1282 rec: &mail.Record{
1283 Provisioned: &mail.Provisioned{KeyID: "AKIA4", Active: false, PauseKind: mail.PauseKindRate, At: now},
1284 },
1285 check: func(t *testing.T, card *MailCard) {
1286 if !card.Paused || card.PauseKind != mail.PauseKindRate {
1287 t.Fatalf("paused-rate card = %+v", card)
1288 }
1289 },
1290 },
1291 {
1292 name: "paused-manual",
1293 app: "mail-paused-manual",
1294 rec: &mail.Record{
1295 Provisioned: &mail.Provisioned{KeyID: "AKIA5", Active: false, PauseKind: "", At: now},
1296 },
1297 check: func(t *testing.T, card *MailCard) {
1298 if !card.Paused || card.PauseKind != "" {
1299 t.Fatalf("paused-manual card = %+v", card)
1300 }
1301 },
1302 },
1303 {
1304 name: "with standalone credentials",
1305 app: "mail-standalone",
1306 rec: &mail.Record{
1307 Standalone: []*mail.Standalone{
1308 {Name: "mailgun-fallback", KeyID: "AKIASTAND", At: now.Add(-24 * time.Hour), By: "paul@keymail.dev"},
1309 },
1310 },
1311 check: func(t *testing.T, card *MailCard) {
1312 if len(card.Standalone) != 1 {
1313 t.Fatalf("standalone card = %+v", card)
1314 }
1315 row := card.Standalone[0]
1316 if row.Name != "mailgun-fallback" || row.KeyID != "AKIASTAND" || row.Age == "" {
1317 t.Fatalf("standalone row = %+v", row)
1318 }
1319 },
1320 },
1321 {
1322 name: "provisioner nil",
1323 app: "mail-no-provisioner",
1324 provisionerNil: true,
1325 check: func(t *testing.T, card *MailCard) {
1326 if card == nil {
1327 t.Fatal("provisioner-nil, absent record returned nil, want a zero card")
1328 }
1329 if card.Available {
1330 t.Fatal("Available = true with no MailProvisioner configured")
1331 }
1332 if card.Declared || card.Granted || card.Provisioned {
1333 t.Fatalf("provisioner-nil card is not otherwise zero: %+v", card)
1334 }
1335 },
1336 },
1337 }
1338
1339 for _, tc := range cases {
1340 tc := tc
1341 t.Run(tc.name, func(t *testing.T) {
1342 fx := newConsoleFixture(t)
1343 if !tc.provisionerNil {
1344 fx.srv.cfg.MailProvisioner = mail.NewMemProvisioner("1")
1345 }
1346 if tc.rec != nil {
1347 putMailRecord(t, fx, tc.app, tc.rec)
1348 }
1349 card := fx.srv.mailCard(context.Background(), mailCardAccount(fx), tc.app)
1350 tc.check(t, card)
1351 })
1352 }
1353}
1354
1355// unreadableMailStore breaks exactly the email record's read — the state a
1356// bucket hiccup (or a permissions gap on this one object) leaves behind.
1357type unreadableMailStore struct{ release.Store }
1358
1359func (u unreadableMailStore) Get(ctx context.Context, key string) ([]byte, string, error) {
1360 if strings.HasSuffix(key, "/email.json") {
1361 return nil, "", errors.New("AccessDenied: s3://carlos-releases/apps/.../email.json request id XYZ")
1362 }
1363 return u.Store.Get(ctx, key)
1364}
1365
1366// TestMailCardReadErrorReturnsNilNotAZeroCard is storeCard's own discipline,
1367// carried over: "could not read the record" and "there is genuinely no
1368// email" must never render the same way. A zero card here would tell a
1369// member "no email for this app yet, get it with carlos email enable" on a
1370// page that could not actually confirm that — mailCard must instead return
1371// nil, which the template renders as nothing at all about email.
1372func TestMailCardReadErrorReturnsNilNotAZeroCard(t *testing.T) {
1373 fx := newConsoleFixture(t)
1374 fx.srv.cfg.MailProvisioner = mail.NewMemProvisioner("1")
1375 fx.srv.cfg.Releases = unreadableMailStore{fx.rel}
1376
1377 card := fx.srv.mailCard(context.Background(), mailCardAccount(fx), "kass")
1378 if card != nil {
1379 t.Fatalf("a read failure returned %+v, want nil", card)
1380 }
1381}
1382
1383// TestSettingsPageShowsTheEmailCard is the render half: a card field the
1384// template never reads does not exist for the member looking at the page,
1385// and the paused state above all else must reach the HTML — a paused app is
1386// sending nothing, and this must be impossible to miss.
1387func TestSettingsPageShowsTheEmailCard(t *testing.T) {
1388 fx := newConsoleFixture(t)
1389 fx.srv.cfg.MailProvisioner = mail.NewMemProvisioner("1")
1390 putMailRecord(t, fx, "kass", &mail.Record{
1391 Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@kass.example.com", At: time.Now()},
1392 Grant: &mail.GrantBlock{Domains: []string{"kass.example.com"}, DailyCap: 500, At: time.Now()},
1393 Provisioned: &mail.Provisioned{
1394 KeyID: "AKIAPAUSED", Active: false, PauseKind: mail.PauseKindRate, At: time.Now(),
1395 },
1396 Standalone: []*mail.Standalone{{Name: "legacy-notifier", KeyID: "AKIASTAND1", At: time.Now()}},
1397 })
1398 c := signInPaul(t, fx)
1399
1400 status, _, body := get(t, c, fx.u("/apps/kass/settings/email"))
1401 if status != http.StatusOK {
1402 t.Fatalf("settings page = %d:\n%s", status, body)
1403 }
1404 for _, want := range []string{
1405 "CARLOS_SMTP", // the env prefix
1406 "hi@kass.example.com", // the From address
1407 "kass.example.com", // the granted sending domain
1408 "AKIAPAUSED", // the SMTP username
1409 "AKIASTAND1", // the standalone credential's key id
1410 "legacy-notifier", // the standalone credential's name
1411 "Sending is paused", // impossible to miss
1412 "does not clear itself", // the rate-pause distinction from a cap pause
1413 } {
1414 if !strings.Contains(body, want) {
1415 t.Fatalf("the email card is missing %q:\n%s", want, body)
1416 }
1417 }
1418
1419 // An app nobody has declared email for, on a console WITH a provisioner,
1420 // gets the command that can actually run.
1421 freshApp(t, fx, "sprout")
1422 _, _, body = get(t, c, fx.u("/apps/sprout/settings/email"))
1423 if !strings.Contains(body, "carlos email enable --app sprout") {
1424 t.Fatalf("an undeclared app is not told how to ask for email:\n%s", body)
1425 }
1426}
1427
1428// TestSettingsPageEmailUnavailableOnAConsoleWithNoProvisioner is the
1429// judgement call the brief calls out: a deployment with no MailProvisioner
1430// must never suggest a command that mailProvisionerGate would 404 —
1431// suggesting `carlos email enable` there would tell the member to run
1432// something that cannot work.
1433func TestSettingsPageEmailUnavailableOnAConsoleWithNoProvisioner(t *testing.T) {
1434 fx := newConsoleFixture(t)
1435 fx.srv.cfg.MailProvisioner = nil
1436 c := signInPaul(t, fx)
1437
1438 status, _, body := get(t, c, fx.u("/apps/kass/settings/email"))
1439 if status != http.StatusOK {
1440 t.Fatalf("settings page = %d:\n%s", status, body)
1441 }
1442 if !strings.Contains(body, "not available on this deployment") {
1443 t.Fatalf("a provisioner-less console does not say email is unavailable:\n%s", body)
1444 }
1445 if strings.Contains(body, "carlos email enable") {
1446 t.Fatalf("a provisioner-less console still suggests a command that would 404:\n%s", body)
1447 }
1448}
1449
1450// The Settings page was the last first-party surface still telling the
1451// pre-#299 story: it read THIS box's registry copy and printed a DNS word,
1452// while `carlos domains list` read the fleet-wide sweep and could be saying
1453// something else about the same hostname at the same moment. Both must now
1454// come from domainState over the same inputs.
1455func TestSettingsDomainsCarryTheSameStateTheAPIDoorDerives(t *testing.T) {
1456 fx := newConsoleFixture(t)
1457 ctx := context.Background()
1458 now := time.Now()
1459 acct, err := fx.st.AccountBySqid(fx.sqid)
1460 if err != nil || acct == nil {
1461 t.Fatalf("fixture account %s: %v", fx.sqid, err)
1462 }
1463 if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{
1464 Host: "www.customer.test", Account: fx.sqid, App: "kass",
1465 }); err != nil {
1466 t.Fatal(err)
1467 }
1468 if err := status.WriteBox(ctx, fx.rel, status.Box{
1469 GeneratedAt: now, Label: "ada-reporting", Region: "eu-west-1",
1470 Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{
1471 "www.customer.test": {Route: "kass.carlosapp.dev", DNSStatus: "elsewhere", DNSCheckedAt: now.Unix()},
1472 }},
1473 }); err != nil {
1474 t.Fatal(err)
1475 }
1476 got := fx.srv.settingsDomains(ctx, acct, "kass", nil)
1477 if len(got) != 1 {
1478 t.Fatalf("rows = %+v", got)
1479 }
1480 wantState, wantReason, wantDetail := domainState(
1481 status.AliasReading{Route: "kass.carlosapp.dev", DNSStatus: "elsewhere", DNSCheckedAt: now.Unix()},
1482 true, nil, now)
1483 if got[0].State != wantState || got[0].Reason != wantReason || got[0].Detail != wantDetail {
1484 t.Errorf("row = state %q reason %q detail %q; want %q / %q / %q — the page and the CLI must derive one verdict",
1485 got[0].State, got[0].Reason, got[0].Detail, wantState, wantReason, wantDetail)
1486 }
1487 if got[0].Status() != wantState {
1488 t.Errorf("Status() = %q, want the derived state %q", got[0].Status(), wantState)
1489 }
1490}
1491
1492// A failing certificate reaches the page, through the same warm-outcome
1493// record the API doors read (#299 §3e). This is the surface a member is most
1494// likely to be looking at when it happens.
1495func TestSettingsDomainsShowAFailedCertificate(t *testing.T) {
1496 fx := newConsoleFixture(t)
1497 ctx := context.Background()
1498 now := time.Now()
1499 acct, err := fx.st.AccountBySqid(fx.sqid)
1500 if err != nil || acct == nil {
1501 t.Fatalf("fixture account %s: %v", fx.sqid, err)
1502 }
1503 if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{
1504 Host: "broken.customer.test", Account: fx.sqid, App: "kass",
1505 }); err != nil {
1506 t.Fatal(err)
1507 }
1508 if err := status.WriteBox(ctx, fx.rel, status.Box{
1509 GeneratedAt: now, Label: "ada-reporting", Region: "eu-west-1",
1510 Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{
1511 "broken.customer.test": {Route: "kass.carlosapp.dev", DNSStatus: "pointing", DNSCheckedAt: now.Unix()},
1512 }},
1513 }); err != nil {
1514 t.Fatal(err)
1515 }
1516 body, err := certwarm.Record{Host: "broken.customer.test", Attempts: domainFailedAttempts,
1517 Class: certwarm.ClassOther, NextAttemptAt: now.Add(time.Hour).Unix()}.Encode(now)
1518 if err != nil {
1519 t.Fatal(err)
1520 }
1521 if _, err := fx.rel.Put(ctx, certwarm.Key("broken.customer.test"), body, ""); err != nil {
1522 t.Fatal(err)
1523 }
1524 got := fx.srv.settingsDomains(ctx, acct, "kass", nil)
1525 if len(got) != 1 || got[0].State != StateFailed {
1526 t.Fatalf("rows = %+v, want one row in state %q", got, StateFailed)
1527 }
1528 // And it EXPLAINS. A one-word "failed" with no sentence under it is the
1529 // worst version of this row.
1530 if got[0].Explain() == "" {
1531 t.Error("a failed certificate row carries no explanation")
1532 }
1533}
1534
1535// Explain is what keeps the page readable: a working domain gets no
1536// paragraph, because a sentence repeated under every healthy row trains
1537// people to skip the one under the broken row.
1538func TestDomainViewExplainStaysQuietWhenThereIsNothingToSay(t *testing.T) {
1539 for _, tc := range []struct {
1540 name string
1541 d DomainView
1542 want string
1543 }{
1544 {"live says nothing", DomainView{State: StateLive, Detail: "served over HTTPS"}, ""},
1545 {"a problem says its detail", DomainView{State: StateMisdirected, Detail: "points elsewhere"}, "points elsewhere"},
1546 {"a problem with records says both",
1547 DomainView{State: StatePendingDNS, Detail: "no record yet", Guidance: "Point an A record."},
1548 "no record yet Point an A record."},
1549 // A row the shared view builder produced, with no fleet fold behind
1550 // it, must render exactly as it always has.
1551 {"no state falls back to guidance", DomainView{Guidance: "Point an A record."}, "Point an A record."},
1552 {"no state and no guidance", DomainView{}, ""},
1553 } {
1554 if got := tc.d.Explain(); got != tc.want {
1555 t.Errorf("%s: Explain() = %q, want %q", tc.name, got, tc.want)
1556 }
1557 }
1558}
1559