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 fleets API (fleets.go): account-owned Fleet/FleetBox rows and app
4// placement. The properties that matter, in the order they'd hurt if lost:
5// a fleet name owned by another account resolves the SAME byte-identical
6// 404 an unknown one does (fleet names are one flat, cross-account
7// namespace — see fleets.go's package doc), a minted or rotated token's
8// plaintext appears exactly once and its stored hash is really its sha256,
9// detach flips status without touching the token, and `place` stamps BOTH
10// an app's routes and its already-provisioned instance records (the
11// tripwire the brief calls out by name) while refusing a target fleet the
12// caller does not own.
13
14import (
15 "context"
16 "crypto/sha256"
17 "encoding/hex"
18 "errors"
19 "io"
20 "net/http"
21 "net/url"
22 "os"
23 "strings"
24 "testing"
25 "time"
26
27 "github.com/carlosframework/platform/internal/provision"
28 "github.com/carlosframework/platform/internal/registry"
29 "github.com/carlosframework/platform/internal/release"
30 "github.com/carlosframework/platform/internal/status"
31)
32
33// fleetFixture is a console with paul (the fixture's default ops-account
34// owner, sqid fx.sqid) holding a live CLI token — owner-level, which is
35// exactly the gate every fleet door grants (there is no member/owner split
36// in this API; the account-scoped 404 is the only gate).
37func fleetFixture(t *testing.T, code string) (*consoleFixture, string) {
38 t.Helper()
39 fx := newConsoleFixture(t)
40 return fx, cliToken(t, fx, "paul@keymail.dev", code)
41}
42
43func fleetsURL(fx *consoleFixture, acct string) string {
44 return fx.ts.URL + "/api/cli/fleets/" + acct
45}
46
47// openTestReg opens a second, independent handle on the fixture's registry
48// file to seed rows the console API itself has no door for (routes) —
49// called before the console's own handlers ever touch this path, so there
50// is no cross-connection WAL-staleness risk (registry.go's List/Fingerprint
51// doc comment): the console's cached handle (openRegistry, view.go) does
52// not exist yet, and every write below is committed before that first call.
53func openTestReg(t *testing.T, fx *consoleFixture) *registry.Registry {
54 t.Helper()
55 reg, err := registry.Open(fx.regPath)
56 if err != nil {
57 t.Fatal(err)
58 }
59 return reg
60}
61
62// TestFleetCreateListRoundTrip: create, then list, and the created fleet
63// comes back with an empty (never null) boxes array.
64func TestFleetCreateListRoundTrip(t *testing.T) {
65 fx, token := fleetFixture(t, "code-fleet-roundtrip")
66 status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
67 map[string]string{"name": "acme", "bucket": "acme-data"}, token)
68 if status != http.StatusOK {
69 t.Fatalf("create = %d %v, want 200", status, body)
70 }
71 f, ok := body["fleet"].(map[string]any)
72 if !ok {
73 t.Fatalf("create body = %v, want a \"fleet\" object", body)
74 }
75 if f["name"] != "acme" || f["bucket"] != "acme-data" {
76 t.Fatalf("fleet = %v, want name=acme bucket=acme-data", f)
77 }
78 boxes, ok := f["boxes"].([]any)
79 if !ok || len(boxes) != 0 {
80 t.Fatalf("fleet.boxes = %v, want an empty array", f["boxes"])
81 }
82
83 status, body = getJSON(t, fleetsURL(fx, fx.sqid), token)
84 if status != http.StatusOK {
85 t.Fatalf("list = %d %v, want 200", status, body)
86 }
87 list, ok := body["fleets"].([]any)
88 if !ok || len(list) != 1 {
89 t.Fatalf("list = %v, want exactly the one fleet", body)
90 }
91 got := list[0].(map[string]any)
92 if got["name"] != "acme" || got["bucket"] != "acme-data" {
93 t.Fatalf("listed fleet = %v, want name=acme bucket=acme-data", got)
94 }
95}
96
97// TestFleetCreateDuplicateName409: the exact "that name is taken" wording
98// handleAPICreateApp already uses for the same conflict on apps.
99func TestFleetCreateDuplicateName409(t *testing.T) {
100 fx, token := fleetFixture(t, "code-fleet-dup")
101 u := fleetsURL(fx, fx.sqid) + "/new"
102 if status, body := postJSON(t, u, map[string]string{"name": "acme", "bucket": "bkt-1"}, token); status != http.StatusOK {
103 t.Fatalf("first create = %d %v, want 200", status, body)
104 }
105 status, body := postJSON(t, u, map[string]string{"name": "acme", "bucket": "bkt-2"}, token)
106 if status != http.StatusConflict {
107 t.Fatalf("duplicate create = %d %v, want 409", status, body)
108 }
109 if body["error"] != "that name is taken" {
110 t.Fatalf("duplicate error = %q, want the exact convention", body["error"])
111 }
112}
113
114// TestFleetAddBoxTokenHash: the minted token is 64 hex characters, its
115// sha256 hex is exactly what landed in the registry row, and it never
116// appears anywhere in the create-fleet or list responses.
117func TestFleetAddBoxTokenHash(t *testing.T) {
118 fx, token := fleetFixture(t, "code-fleet-addbox")
119 if status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
120 map[string]string{"name": "acme", "bucket": "acme-data"}, token); status != http.StatusOK {
121 t.Fatalf("create fleet = %d %v", status, body)
122 }
123 status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
124 map[string]string{"label": "pi-1"}, token)
125 if status != http.StatusOK {
126 t.Fatalf("add-box = %d %v, want 200", status, body)
127 }
128 plaintext, _ := body["token"].(string)
129 if len(plaintext) != 64 {
130 t.Fatalf("token = %q, want 64 hex characters", plaintext)
131 }
132 if body["label"] != "pi-1" {
133 t.Fatalf("label = %v, want pi-1", body["label"])
134 }
135 sum := sha256.Sum256([]byte(plaintext))
136 wantHash := hex.EncodeToString(sum[:])
137
138 reg := openTestReg(t, fx)
139 boxes, err := reg.ListFleetBoxes("acme")
140 if err != nil {
141 t.Fatal(err)
142 }
143 if len(boxes) != 1 || boxes[0].Label != "pi-1" || boxes[0].Status != "active" {
144 t.Fatalf("registry boxes = %+v", boxes)
145 }
146 if boxes[0].TokenHash != wantHash {
147 t.Fatalf("stored hash = %s, want sha256(token) = %s", boxes[0].TokenHash, wantHash)
148 }
149}
150
151// TestFleetRotateBoxChangesHash: a fresh token comes back, distinct from the
152// mint's, and the stored hash moves to match it.
153func TestFleetRotateBoxChangesHash(t *testing.T) {
154 fx, token := fleetFixture(t, "code-fleet-rotate")
155 if status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
156 map[string]string{"name": "acme", "bucket": "acme-data"}, token); status != http.StatusOK {
157 t.Fatalf("create fleet = %d %v", status, body)
158 }
159 _, addBody := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes", map[string]string{"label": "pi-1"}, token)
160 first, _ := addBody["token"].(string)
161
162 status, rotBody := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes/pi-1/rotate", map[string]string{}, token)
163 if status != http.StatusOK {
164 t.Fatalf("rotate = %d %v, want 200", status, rotBody)
165 }
166 second, _ := rotBody["token"].(string)
167 if second == "" || second == first {
168 t.Fatalf("rotated token = %q, want a fresh 64-hex value distinct from %q", second, first)
169 }
170
171 reg := openTestReg(t, fx)
172 boxes, err := reg.ListFleetBoxes("acme")
173 if err != nil {
174 t.Fatal(err)
175 }
176 sum := sha256.Sum256([]byte(second))
177 wantHash := hex.EncodeToString(sum[:])
178 if len(boxes) != 1 || boxes[0].TokenHash != wantHash {
179 t.Fatalf("registry boxes = %+v, want hash %s", boxes, wantHash)
180 }
181 // The OLD hash no longer matches anything stored.
182 oldSum := sha256.Sum256([]byte(first))
183 if boxes[0].TokenHash == hex.EncodeToString(oldSum[:]) {
184 t.Fatalf("registry still carries the pre-rotation hash")
185 }
186}
187
188// TestFleetDetachBoxFlipsStatus: detach flips status to "detached" and
189// leaves the token hash untouched — rotation and detachment are different
190// axes (fleets.go's rotate doc comment).
191func TestFleetDetachBoxFlipsStatus(t *testing.T) {
192 fx, token := fleetFixture(t, "code-fleet-detach")
193 if status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
194 map[string]string{"name": "acme", "bucket": "acme-data"}, token); status != http.StatusOK {
195 t.Fatalf("create fleet = %d %v", status, body)
196 }
197 postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes", map[string]string{"label": "pi-1"}, token)
198
199 status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes/pi-1/detach", map[string]string{}, token)
200 if status != http.StatusOK {
201 t.Fatalf("detach = %d %v, want 200", status, body)
202 }
203
204 reg := openTestReg(t, fx)
205 boxes, err := reg.ListFleetBoxes("acme")
206 if err != nil {
207 t.Fatal(err)
208 }
209 if len(boxes) != 1 || boxes[0].Status != "detached" {
210 t.Fatalf("registry boxes = %+v, want status detached", boxes)
211 }
212
213 // The list door reflects it too.
214 _, listBody := getJSON(t, fleetsURL(fx, fx.sqid), token)
215 fleets := listBody["fleets"].([]any)
216 f := fleets[0].(map[string]any)
217 bx := f["boxes"].([]any)[0].(map[string]any)
218 if bx["status"] != "detached" {
219 t.Fatalf("listed box status = %v, want detached", bx["status"])
220 }
221}
222
223// TestAPIFleetTagBox: a full-replace tag write lands in the registry and
224// shows up on the list door's "tags" field; bad grammar 400s; an unknown
225// label 404s byte-identical to rotate's own unknown-label 404 (both are
226// jsonError's "no such box", not the plain apiFleet 404); a foreign-owned
227// fleet 404s the ordinary apiFleet way (byte-identical to canonicalNotFound,
228// same as every other door in TestFleetCrossAccountAccess404).
229func TestAPIFleetTagBox(t *testing.T) {
230 fx, token := fleetFixture(t, "code-fleet-tagbox")
231 if status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
232 map[string]string{"name": "pi", "bucket": "pi-data"}, token); status != http.StatusOK {
233 t.Fatalf("create fleet = %d %v", status, body)
234 }
235 if status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/pi/boxes", map[string]string{"label": "box-a"}, token); status != http.StatusOK {
236 t.Fatalf("add-box = %d %v", status, body)
237 }
238
239 status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/pi/boxes/box-a/tags",
240 map[string]string{"tags": "office,free"}, token)
241 if status != http.StatusOK {
242 t.Fatalf("tag-box = %d %v, want 200", status, body)
243 }
244
245 reg := openTestReg(t, fx)
246 boxes, err := reg.ListFleetBoxes("pi")
247 if err != nil {
248 t.Fatal(err)
249 }
250 if len(boxes) != 1 || boxes[0].Tags != "office,free" {
251 t.Fatalf("registry boxes = %+v, want tags office,free", boxes)
252 }
253
254 _, listBody := getJSON(t, fleetsURL(fx, fx.sqid), token)
255 fleets := listBody["fleets"].([]any)
256 f := fleets[0].(map[string]any)
257 bx := f["boxes"].([]any)[0].(map[string]any)
258 if bx["tags"] != "office,free" {
259 t.Fatalf("listed box tags = %v, want office,free", bx["tags"])
260 }
261
262 // Bad grammar (uppercase) is a 400, and must not have touched the row.
263 status, body = postJSON(t, fleetsURL(fx, fx.sqid)+"/pi/boxes/box-a/tags",
264 map[string]string{"tags": "Office"}, token)
265 if status != http.StatusBadRequest {
266 t.Fatalf("bad-grammar tag-box = %d %v, want 400", status, body)
267 }
268 boxes, err = reg.ListFleetBoxes("pi")
269 if err != nil {
270 t.Fatal(err)
271 }
272 if boxes[0].Tags != "office,free" {
273 t.Fatalf("registry tags after rejected write = %q, want unchanged office,free", boxes[0].Tags)
274 }
275
276 // Unknown label: byte-identical to rotate's own unknown-label 404.
277 rotStatus, rotHeader, rotBody := rawReq(t, http.MethodPost, fleetsURL(fx, fx.sqid)+"/pi/boxes/ghost/rotate", token, `{}`)
278 tagStatus, tagHeader, tagBody := rawReq(t, http.MethodPost, fleetsURL(fx, fx.sqid)+"/pi/boxes/ghost/tags", token, `{"tags":"x"}`)
279 if tagStatus != http.StatusNotFound {
280 t.Fatalf("tag-box unknown label = %d, want 404", tagStatus)
281 }
282 if tagStatus != rotStatus || tagBody != rotBody || tagHeader.Get("Content-Type") != rotHeader.Get("Content-Type") {
283 t.Fatalf("tag-box unknown-label 404 = %d %q (%s), want byte-identical to rotate's %d %q (%s)",
284 tagStatus, tagBody, tagHeader.Get("Content-Type"), rotStatus, rotBody, rotHeader.Get("Content-Type"))
285 }
286
287 // Foreign-owned fleet: the ordinary apiFleet 404, same as every other
288 // door in TestFleetCrossAccountAccess404.
289 now := time.Now()
290 if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil {
291 t.Fatal(err)
292 }
293 bobToken := cliToken(t, fx, "bob@keymail.dev", "code-fleet-tagbox-bob")
294 want := canonicalNotFound(t)
295 fStatus, fHeader, fOut := rawReq(t, http.MethodPost, fleetsURL(fx, "cdef")+"/pi/boxes/box-a/tags", bobToken, `{"tags":"x"}`)
296 assertNotFound(t, "tag-box on a foreign-owned fleet", want, fStatus, fHeader, fOut)
297}
298
299// TestFleetCrossAccountAccess404: a fleet named under the caller's OWN
300// account segment but owned by a DIFFERENT account resolves the exact same
301// bytes an outright nonexistent fleet name does — fleet names are one flat
302// namespace (fleets.go's package doc), so this is the probe surface that
303// matters here, distinct from (and in addition to) apiAccount's ordinary
304// unknown/foreign-account 404.
305func TestFleetCrossAccountAccess404(t *testing.T) {
306 fx, ownerToken := fleetFixture(t, "code-fleet-cross-owner")
307 if status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
308 map[string]string{"name": "acme", "bucket": "acme-data"}, ownerToken); status != http.StatusOK {
309 t.Fatalf("create fleet = %d %v", status, body)
310 }
311
312 now := time.Now()
313 if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil {
314 t.Fatal(err)
315 }
316 bobToken := cliToken(t, fx, "bob@keymail.dev", "code-fleet-cross-bob")
317
318 want := canonicalNotFound(t)
319 cases := []struct {
320 what, method, url, body string
321 }{
322 {"list boxes via add-box on a foreign-owned fleet", http.MethodPost, fleetsURL(fx, "cdef") + "/acme/boxes", `{"label":"x"}`},
323 {"add-box on a genuinely nonexistent fleet", http.MethodPost, fleetsURL(fx, "cdef") + "/ghost/boxes", `{"label":"x"}`},
324 {"rotate on a foreign-owned fleet", http.MethodPost, fleetsURL(fx, "cdef") + "/acme/boxes/pi-1/rotate", `{}`},
325 {"detach on a foreign-owned fleet", http.MethodPost, fleetsURL(fx, "cdef") + "/acme/boxes/pi-1/detach", `{}`},
326 }
327 for _, c := range cases {
328 status, h, out := rawReq(t, c.method, c.url, bobToken, c.body)
329 assertNotFound(t, c.what, want, status, h, out)
330 }
331
332 // Bob's own account is genuinely untouched: an honest fleet of his own
333 // still works, proving the refusal above is about OWNERSHIP, not a
334 // broken door.
335 if status, body := postJSON(t, fleetsURL(fx, "cdef")+"/new", map[string]string{"name": "widget-fleet", "bucket": "w-data"}, bobToken); status != http.StatusOK {
336 t.Fatalf("bob's own fleet create = %d %v, want 200", status, body)
337 }
338}
339
340// TestFleetMissingBearerIs401: every fleet door, and place, answer the same
341// 401 with no credential at all.
342func TestFleetMissingBearerIs401(t *testing.T) {
343 fx, token := fleetFixture(t, "code-fleet-401")
344 postJSON(t, fleetsURL(fx, fx.sqid)+"/new", map[string]string{"name": "acme", "bucket": "acme-data"}, token)
345 postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes", map[string]string{"label": "pi-1"}, token)
346
347 for _, c := range []struct{ method, url, body string }{
348 {http.MethodPost, fleetsURL(fx, fx.sqid) + "/new", `{"name":"x","bucket":"x-data"}`},
349 {http.MethodGet, fleetsURL(fx, fx.sqid), ""},
350 {http.MethodPost, fleetsURL(fx, fx.sqid) + "/acme/boxes", `{"label":"x"}`},
351 {http.MethodPost, fleetsURL(fx, fx.sqid) + "/acme/boxes/pi-1/rotate", `{}`},
352 {http.MethodPost, fleetsURL(fx, fx.sqid) + "/acme/boxes/pi-1/detach", `{}`},
353 {http.MethodPost, fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/place", `{"target":"fleet/acme"}`},
354 } {
355 if status, _, out := rawReq(t, c.method, c.url, "", c.body); status != http.StatusUnauthorized {
356 t.Errorf("%s %s with no token = %d %s, want 401", c.method, c.url, status, out)
357 }
358 }
359}
360
361// placeURL is the place door for one app under the fixture's own account.
362func placeURL(fx *consoleFixture, app string) string {
363 return fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/" + app + "/place"
364}
365
366// TestAppPlacementFallsBackToRouteWhenStoreEmpty: an app placed by an OLDER
367// build — before the store's placement column existed (2026-08-06 ruling) —
368// has its placement fact only in its routes, stamped directly rather than
369// through handleAPIPlace (which now also writes the store). appPlacement
370// must still find it: Store.AppPlacement answers "" for this app, so the
371// derivation falls back to walking routes, exactly as it always did before
372// the store became the primary source.
373func TestAppPlacementFallsBackToRouteWhenStoreEmpty(t *testing.T) {
374 fx := newConsoleFixture(t)
375 now := time.Now()
376 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
377 t.Fatal(err)
378 }
379 reg := openTestReg(t, fx)
380 if err := reg.Upsert(registry.Route{
381 Host: "acme.go.tito.io", Kind: "instance", App: "titogo", Account: fx.sqid, Region: "fleet/pi",
382 }); err != nil {
383 t.Fatal(err)
384 }
385 // Sanity: the store genuinely has nothing recorded for this app.
386 if p, err := fx.st.AppPlacement(OpsAccountID, "titogo"); err != nil || p != "" {
387 t.Fatalf("store placement before appPlacement runs = %q %v, want \"\" (unset)", p, err)
388 }
389
390 acct := &Account{ID: OpsAccountID, Sqid: fx.sqid}
391 got, err := fx.srv.appPlacement(reg, acct, "titogo")
392 if err != nil {
393 t.Fatal(err)
394 }
395 if got != "fleet/pi" {
396 t.Fatalf("appPlacement = %q, want the route-derived fleet/pi", got)
397 }
398}
399
400// TestPlaceStampsRoutesAndInstances is the tripwire test the brief calls out
401// by name: placing an app stamps BOTH its routes' Region (walked from the
402// registry, SetPlacement per host) AND its already-provisioned instance
403// records' Region (provision.SetRecordRegion) — the second half is what
404// makes placement real for instances that already exist, not only future
405// ones. Clearing (target "") repoints both back to "".
406func TestPlaceStampsRoutesAndInstances(t *testing.T) {
407 fx, token := fleetFixture(t, "code-place-tripwire")
408 now := time.Now()
409 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
410 t.Fatal(err)
411 }
412
413 // Seed two routes for the app, owned by the fixture account, BEFORE any
414 // console fleets/registry call — see openTestReg's doc comment for why
415 // that ordering matters.
416 reg := openTestReg(t, fx)
417 for _, host := range []string{"acme.go.tito.io", "beta.go.tito.io"} {
418 if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", App: "titogo", Account: fx.sqid}); err != nil {
419 t.Fatal(err)
420 }
421 }
422 // A route for a DIFFERENT app, same account — must be left alone.
423 if err := reg.Upsert(registry.Route{Host: "other.go.tito.io", Kind: "instance", App: "otherapp", Account: fx.sqid}); err != nil {
424 t.Fatal(err)
425 }
426
427 // The fleet target must exist and be owned by the caller.
428 if status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
429 map[string]string{"name": "pi", "bucket": "pi-fleet-data"}, token); status != http.StatusOK {
430 t.Fatalf("create fleet = %d %v", status, body)
431 }
432
433 // One already-provisioned instance record for the app.
434 ctx := context.Background()
435 rec := provision.Record{Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "titogo", Account: fx.sqid,
436 Region: "ie", Channel: "stable", CreatedAt: now.UTC()}
437 if err := provision.CreateRecord(ctx, fx.rel, rec); err != nil {
438 t.Fatal(err)
439 }
440
441 status, body := postJSON(t, placeURL(fx, "titogo"), map[string]string{"target": "fleet/pi"}, token)
442 if status != http.StatusOK {
443 t.Fatalf("place = %d %v, want 200", status, body)
444 }
445
446 for _, host := range []string{"acme.go.tito.io", "beta.go.tito.io"} {
447 rt, err := reg.Get(host)
448 if err != nil || rt == nil {
449 t.Fatalf("route %s: %v", host, err)
450 }
451 if rt.Region != "fleet/pi" {
452 t.Errorf("route %s Region = %q, want fleet/pi", host, rt.Region)
453 }
454 }
455 other, err := reg.Get("other.go.tito.io")
456 if err != nil || other == nil {
457 t.Fatalf("other-app route: %v", err)
458 }
459 if other.Region != "" {
460 t.Fatalf("a route belonging to a DIFFERENT app was stamped: Region = %q", other.Region)
461 }
462
463 got, err := provision.GetRecord(ctx, fx.rel, fx.sqid, "titogo", "acme.go.tito.io")
464 if err != nil {
465 t.Fatal(err)
466 }
467 if got.Region != "fleet/pi" {
468 t.Fatalf("instance record Region = %q, want fleet/pi — the tripwire: routes-only stamping is not enough", got.Region)
469 }
470 // Nothing else about the record moved.
471 if got.Channel != "stable" || got.Slug != "acme" {
472 t.Fatalf("place touched more than Region: %+v", got)
473 }
474
475 // Clearing repoints both back to "".
476 if status, body := postJSON(t, placeURL(fx, "titogo"), map[string]string{"target": ""}, token); status != http.StatusOK {
477 t.Fatalf("clear place = %d %v, want 200", status, body)
478 }
479 rt, err := reg.Get("acme.go.tito.io")
480 if err != nil || rt == nil || rt.Region != "" {
481 t.Fatalf("route after clearing: %+v, %v, want Region \"\"", rt, err)
482 }
483 got, err = provision.GetRecord(ctx, fx.rel, fx.sqid, "titogo", "acme.go.tito.io")
484 if err != nil || got.Region != "" {
485 t.Fatalf("instance record after clearing: %+v, %v, want Region \"\"", got, err)
486 }
487}
488
489// vanishOnGetStore wraps a release.Store and deletes ONE key out from under
490// itself the first time that key is Get after skip Gets — a stand-in for I4's race: an
491// instance record retired (DeleteRecord) in the gap between handleAPIPlace's
492// List of a prefix and its per-key SetRecordRegion write reaching that same
493// key. Every other key, and every other method, is the embedded Store's real
494// behavior untouched.
495type vanishOnGetStore struct {
496 release.Store
497 vanish string
498 skip int // Gets of vanish to let through first
499 deleted bool
500}
501
502func (v *vanishOnGetStore) Get(ctx context.Context, key string) ([]byte, string, error) {
503 if key == v.vanish && !v.deleted && v.skip > 0 {
504 v.skip--
505 } else if key == v.vanish && !v.deleted {
506 v.deleted = true
507 if err := v.Store.Delete(ctx, key); err != nil {
508 return nil, "", err
509 }
510 }
511 return v.Store.Get(ctx, key)
512}
513
514// TestPlaceSkipsVanishedRecordMidWalk is I4 (final customer-fleets review):
515// handleAPIPlace's instance-record walk lists a prefix, then writes each key
516// it found. If one of those records is deleted in the gap between the list
517// and its own write — SetRecordRegion returns an os.ErrNotExist-wrapped
518// error — that must be a logged skip, not a 502 that fails the whole
519// placement (and not silently swallowing some OTHER error, which still must
520// fail the call). The surviving record must still get stamped.
521func TestPlaceSkipsVanishedRecordMidWalk(t *testing.T) {
522 fx, token := fleetFixture(t, "code-place-vanish")
523 now := time.Now()
524 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
525 t.Fatal(err)
526 }
527 reg := openTestReg(t, fx)
528 for _, host := range []string{"acme.go.tito.io", "ghost.go.tito.io"} {
529 if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", App: "titogo", Account: fx.sqid}); err != nil {
530 t.Fatal(err)
531 }
532 }
533 if status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
534 map[string]string{"name": "pi", "bucket": "pi-fleet-data"}, token); status != http.StatusOK {
535 t.Fatalf("create fleet = %d %v", status, body)
536 }
537
538 ctx := context.Background()
539 for _, host := range []string{"acme.go.tito.io", "ghost.go.tito.io"} {
540 rec := provision.Record{Schema: 1, Host: host, Slug: strings.SplitN(host, ".", 2)[0], App: "titogo",
541 Account: fx.sqid, Channel: "stable", CreatedAt: now.UTC()}
542 if err := provision.CreateRecord(ctx, fx.rel, rec); err != nil {
543 t.Fatal(err)
544 }
545 }
546
547 // Swap the server's store for one that deletes ghost's record the
548 // instant handleAPIPlace's walk tries to read it — after the List that
549 // found it, exactly the race SetRecordRegion's os.ErrNotExist-wrapped
550 // return exists to name.
551 ghostKey := provision.RecordKey(fx.sqid, "titogo", "ghost.go.tito.io")
552 // skip 1: the place's strict read (seedmove.go) reads ghost first.
553 fx.srv.cfg.Releases = &vanishOnGetStore{Store: fx.rel, vanish: ghostKey, skip: 1}
554
555 status, body := postJSON(t, placeURL(fx, "titogo"), map[string]string{"target": "fleet/pi"}, token)
556 if status != http.StatusOK {
557 t.Fatalf("place = %d %v, want 200 (a vanished record mid-walk must not 502 the call)", status, body)
558 }
559
560 got, err := provision.GetRecord(ctx, fx.rel, fx.sqid, "titogo", "acme.go.tito.io")
561 if err != nil {
562 t.Fatal(err)
563 }
564 if got.Region != "fleet/pi" {
565 t.Fatalf("surviving record Region = %q, want fleet/pi — a vanished sibling must not stop the rest of the walk", got.Region)
566 }
567 if _, err := provision.GetRecord(ctx, fx.rel, fx.sqid, "titogo", "ghost.go.tito.io"); !errors.Is(err, os.ErrNotExist) {
568 t.Fatalf("ghost record: %v, want it to stay gone", err)
569 }
570}
571
572// TestPlaceRefusesForeignFleet: placing onto a fleet owned by a DIFFERENT
573// account is refused (the same byte-identical 404 apiFleet gives elsewhere),
574// and nothing about the app's routes or instances is touched.
575func TestPlaceRefusesForeignFleet(t *testing.T) {
576 fx, token := fleetFixture(t, "code-place-foreign")
577 now := time.Now()
578 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
579 t.Fatal(err)
580 }
581 reg := openTestReg(t, fx)
582 if err := reg.Upsert(registry.Route{Host: "acme.go.tito.io", Kind: "instance", App: "titogo", Account: fx.sqid}); err != nil {
583 t.Fatal(err)
584 }
585 ctx := context.Background()
586 rec := provision.Record{Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "titogo", Account: fx.sqid,
587 Channel: "stable", CreatedAt: now.UTC()}
588 if err := provision.CreateRecord(ctx, fx.rel, rec); err != nil {
589 t.Fatal(err)
590 }
591
592 // A second account with its OWN fleet, "widgets".
593 if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil {
594 t.Fatal(err)
595 }
596 bobToken := cliToken(t, fx, "bob@keymail.dev", "code-place-foreign-bob")
597 if status, body := postJSON(t, fleetsURL(fx, "cdef")+"/new",
598 map[string]string{"name": "acme", "bucket": "bob-data"}, bobToken); status != http.StatusOK {
599 t.Fatalf("bob's fleet create = %d %v", status, body)
600 }
601
602 want := canonicalNotFound(t)
603 status, h, out := rawReq(t, http.MethodPost, placeURL(fx, "titogo"), token, `{"target":"fleet/acme"}`)
604 assertNotFound(t, "place onto a foreign-owned fleet", want, status, h, out)
605
606 // Nothing moved: not the route, not the record.
607 rt, err := reg.Get("acme.go.tito.io")
608 if err != nil || rt == nil || rt.Region != "" {
609 t.Fatalf("route after a refused place: %+v, %v, want untouched (Region \"\")", rt, err)
610 }
611 got, err := provision.GetRecord(ctx, fx.rel, fx.sqid, "titogo", "acme.go.tito.io")
612 if err != nil || got.Region != "" {
613 t.Fatalf("instance record after a refused place: %+v, %v, want untouched (Region \"\")", got, err)
614 }
615}
616
617// TestPlaceInvalidTargetIs400: a target that doesn't parse as "" or
618// "fleet/<name>" is a plain 400, not a 404 — it never reaches the
619// fleet-ownership question at all.
620func TestPlaceInvalidTargetIs400(t *testing.T) {
621 fx, token := fleetFixture(t, "code-place-badtarget")
622 now := time.Now()
623 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
624 t.Fatal(err)
625 }
626 status, body := postJSON(t, placeURL(fx, "titogo"), map[string]string{"target": "eu"}, token)
627 if status != http.StatusBadRequest {
628 t.Fatalf("place with a bare region code = %d %v, want 400", status, body)
629 }
630}
631
632// ---- Task 9: the console fleet page (owner view) -------------------------
633//
634// The properties that matter here, same ordering rule as the API section
635// above: a non-member gets the byte-identical 404 an unrouted URL gets, a
636// member who isn't the owner sees the same data with no mutation forms, a
637// stale heartbeat renders "unreachable" and never its last happy numbers,
638// an unjoined registry row (Task 6's KNOWN DEGRADATION carry) renders safely
639// rather than crashing or borrowing another row's numbers, and a rotated
640// token appears in the response body exactly once, beside the
641// detach-not-rotate warning.
642
643// writeFleetHeartbeat lands one box snapshot at
644// fleets/<fleet>/status/boxes/<label>.json — the exact path a real fleet
645// box's agent writes to (release.NewPrefixStore, host/agent.go) — so the
646// page's loader (fleets.go, via status.LoadBoxes over the same prefix
647// store) reads it the same way it would in production.
648func writeFleetHeartbeat(t *testing.T, fx *consoleFixture, fleet string, b status.Box) {
649 t.Helper()
650 prefixed := release.NewPrefixStore(fx.rel, "fleets/"+fleet+"/")
651 if err := status.WriteBox(context.Background(), prefixed, b); err != nil {
652 t.Fatal(err)
653 }
654}
655
656// postFormHeaders is postForm (server_test.go) plus the response headers —
657// needed to assert Cache-Control on the rotate response, which postForm's
658// three-value return has no room for.
659func postFormHeaders(t *testing.T, c *http.Client, u string, form url.Values) (status int, headers http.Header, body string) {
660 t.Helper()
661 res, err := c.PostForm(u, form)
662 if err != nil {
663 t.Fatal(err)
664 }
665 defer res.Body.Close()
666 raw, err := io.ReadAll(res.Body)
667 if err != nil {
668 t.Fatal(err)
669 }
670 return res.StatusCode, res.Header, string(raw)
671}
672
673// TestFleetPageOwnerControlsMemberReadOnly: both a member and the owner see
674// the fleet's boxes — including a fresh box's raw metrics, which this page
675// (unlike the public fleet status page) is allowed to show — but only the
676// owner's page carries the rotate/detach forms. A member's page must not
677// even offer them.
678func TestFleetPageOwnerControlsMemberReadOnly(t *testing.T) {
679 fx, token := fleetFixture(t, "code-fleetpage-owner")
680 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
681 map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
682 t.Fatalf("create fleet = %d %v", st, body)
683 }
684 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
685 map[string]string{"label": "pi-1"}, token); st != http.StatusOK {
686 t.Fatalf("add-box = %d %v", st, body)
687 }
688 writeFleetHeartbeat(t, fx, "acme", status.Box{
689 GeneratedAt: time.Now(), Label: "pi-1",
690 Metrics: &status.BoxMetrics{
691 MemTotalBytes: pi64(2 << 30), MemAvailBytes: pi64(1 << 30),
692 Load1: p(0.42), DiskFreeBytes: pi64(5 << 30),
693 Release: "abc1234", Tunnel: "connected",
694 },
695 })
696 if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
697 t.Fatal(err)
698 }
699
700 // The owner's view: metrics, and both mutation forms.
701 owner := newBrowser(t)
702 signUp(t, fx, owner, "code-fleetpage-owner-browser")
703 ownerStatus, _, ownerBody := get(t, owner, fx.u("/fleets/acme"))
704 if ownerStatus != http.StatusOK {
705 t.Fatalf("owner GET /fleets/acme = %d, want 200:\n%s", ownerStatus, ownerBody)
706 }
707 for _, want := range []string{"pi-1", "connected", "abc1234", "1.0 GB", "2.0 GB", "0.42", "5.0 GB", "Rotate token</button>", "Detach</button>"} {
708 if !strings.Contains(ownerBody, want) {
709 t.Fatalf("owner fleet page missing %q in:\n%s", want, ownerBody)
710 }
711 }
712
713 // The member's view: same data, no forms.
714 fx.setUser("sam@keymail.dev")
715 member := newBrowser(t)
716 signUp(t, fx, member, "code-fleetpage-member-browser")
717 memberStatus, _, memberBody := get(t, member, fx.u("/fleets/acme"))
718 if memberStatus != http.StatusOK {
719 t.Fatalf("member GET /fleets/acme = %d, want 200:\n%s", memberStatus, memberBody)
720 }
721 for _, want := range []string{"pi-1", "connected", "abc1234", "1.0 GB", "2.0 GB", "0.42", "5.0 GB"} {
722 if !strings.Contains(memberBody, want) {
723 t.Fatalf("member fleet page missing %q in:\n%s", want, memberBody)
724 }
725 }
726 for _, forbidden := range []string{"/fleets/acme/rotate", "/fleets/acme/detach", "Rotate token</button>", "Detach</button>"} {
727 if strings.Contains(memberBody, forbidden) {
728 t.Fatalf("member fleet page renders a mutation form (%q) it must not have:\n%s", forbidden, memberBody)
729 }
730 }
731}
732
733// TestFleetPageNonMemberGetsIdenticalNotFound: the browser-side twin of
734// TestFleetCrossAccountAccess404 — a signed-in caller with no membership in
735// the account gets the byte-identical 404 a genuinely unrouted path gets,
736// same assertion shape as TestNonMemberGetsIdenticalNotFound.
737func TestFleetPageNonMemberGetsIdenticalNotFound(t *testing.T) {
738 fx, token := fleetFixture(t, "code-fleetpage-nonmember")
739 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
740 map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
741 t.Fatalf("create fleet = %d %v", st, body)
742 }
743 fx.setUser("bob@keymail.dev")
744 c := newBrowser(t)
745 signUp(t, fx, c, "code-fleetpage-bob")
746
747 nonMemberStatus, _, nonMemberBody := get(t, c, fx.u("/fleets/acme"))
748 ghostStatus, _, ghostBody := get(t, c, fx.ts.URL+"/definitely-not-a-page")
749 if nonMemberStatus != http.StatusNotFound || nonMemberStatus != ghostStatus {
750 t.Fatalf("non-member fleet page = %d, unrouted page = %d; want both 404", nonMemberStatus, ghostStatus)
751 }
752 if nonMemberBody != ghostBody {
753 t.Fatalf("non-member fleet page body differs from an unrouted page's:\n%q\n%q", nonMemberBody, ghostBody)
754 }
755}
756
757// TestFleetPageStaleHeartbeatRendersUnreachable: a heartbeat older than
758// 3×status.SnapshotPeriod must never show its last happy numbers — only
759// that the box is unreachable and how long it's been dark. Same rule
760// TestFleetHostsDetailSuppressesStaleMetrics enforces on the operator page,
761// applied here to the customer's own.
762func TestFleetPageStaleHeartbeatRendersUnreachable(t *testing.T) {
763 fx, token := fleetFixture(t, "code-fleetpage-stale")
764 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
765 map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
766 t.Fatalf("create fleet = %d %v", st, body)
767 }
768 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
769 map[string]string{"label": "pi-1"}, token); st != http.StatusOK {
770 t.Fatalf("add-box = %d %v", st, body)
771 }
772 writeFleetHeartbeat(t, fx, "acme", status.Box{
773 GeneratedAt: time.Now().Add(-time.Hour), Label: "pi-1",
774 Metrics: &status.BoxMetrics{
775 MemTotalBytes: pi64(9 << 30), MemAvailBytes: pi64(7 << 30),
776 Load1: p(8.25), DiskFreeBytes: pi64(3 << 30),
777 Release: "beefcaf", Tunnel: "connected",
778 },
779 })
780
781 c := newBrowser(t)
782 signUp(t, fx, c, "code-fleetpage-stale-browser")
783 st, _, body := get(t, c, fx.u("/fleets/acme"))
784 if st != http.StatusOK {
785 t.Fatalf("GET /fleets/acme = %d, want 200:\n%s", st, body)
786 }
787 if !strings.Contains(body, "unreachable") {
788 t.Fatalf("stale box did not render as unreachable:\n%s", body)
789 }
790 for _, leaked := range []string{"beefcaf", "8.25", "7.0 GB", "9.0 GB", "3.0 GB"} {
791 if strings.Contains(body, leaked) {
792 t.Fatalf("stale box's last reading %q leaked into the page:\n%s", leaked, body)
793 }
794 }
795}
796
797// TestFleetPageDegradesOnHeartbeatReadFailure: a heartbeat read that FAILS
798// (bucket outage, listFailStore below) is not the same claim as "no box has
799// ever heartbeated" — the page still renders (boxes list, placed apps, the
800// registry rows themselves are all untouched by this failure) but with a
801// flash saying the heartbeats specifically could not be read, rather than
802// silently collapsing "unknown" into the same "no heartbeat yet" wording an
803// honestly-never-joined box gets (TestFleetPageUnjoinedRegistryRowRendersSafely).
804func TestFleetPageDegradesOnHeartbeatReadFailure(t *testing.T) {
805 fx, token := fleetFixture(t, "code-fleetpage-hbfail")
806 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
807 map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
808 t.Fatalf("create fleet = %d %v", st, body)
809 }
810 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
811 map[string]string{"label": "pi-1"}, token); st != http.StatusOK {
812 t.Fatalf("add-box = %d %v", st, body)
813 }
814
815 c := newBrowser(t)
816 signUp(t, fx, c, "code-fleetpage-hbfail-browser")
817
818 // Swapped in AFTER sign-in and box creation, neither of which touches
819 // the release store — only the page's own heartbeat read (status.LoadBoxes,
820 // via release.NewPrefixStore) should ever see this failure.
821 fx.srv.cfg.Releases = listFailStore{fx.rel}
822
823 st, _, body := get(t, c, fx.u("/fleets/acme"))
824 if st != http.StatusOK {
825 t.Fatalf("GET /fleets/acme with an unreadable heartbeat store = %d, want 200 (degrade, don't 500):\n%s", st, body)
826 }
827 // html/template escapes the apostrophe (&#39;) — match around it rather
828 // than typing the escaped entity into the test.
829 if !strings.Contains(body, "Could not read this fleet") || !strings.Contains(body, "heartbeats.") {
830 t.Fatalf("heartbeat read failure did not surface its own flash — page rendered as if nothing were wrong:\n%s", body)
831 }
832 if !strings.Contains(body, "pi-1") {
833 t.Fatalf("the registry's own box row (unaffected by the heartbeat failure) went missing too:\n%s", body)
834 }
835}
836
837// TestFleetPageUnjoinedRegistryRowRendersSafely is the Task 6 KNOWN
838// DEGRADATION carry: a registry box row with no heartbeat that ever joined
839// it (pi-2 here — never written at all, standing in for a box heartbeating
840// under a hashed pseudonym per the identity ruling) must render as "no
841// heartbeat yet", never crash, and never show pi-1's numbers under pi-2's
842// label.
843func TestFleetPageUnjoinedRegistryRowRendersSafely(t *testing.T) {
844 fx, token := fleetFixture(t, "code-fleetpage-unjoined")
845 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
846 map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
847 t.Fatalf("create fleet = %d %v", st, body)
848 }
849 for _, label := range []string{"pi-1", "pi-2"} {
850 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
851 map[string]string{"label": label}, token); st != http.StatusOK {
852 t.Fatalf("add-box %s = %d %v", label, st, body)
853 }
854 }
855 // Only pi-1 ever heartbeats.
856 writeFleetHeartbeat(t, fx, "acme", status.Box{
857 GeneratedAt: time.Now(), Label: "pi-1",
858 Metrics: &status.BoxMetrics{Load1: p(1.11), Tunnel: "connected"},
859 })
860
861 c := newBrowser(t)
862 signUp(t, fx, c, "code-fleetpage-unjoined-browser")
863 st, _, body := get(t, c, fx.u("/fleets/acme"))
864 if st != http.StatusOK {
865 t.Fatalf("GET /fleets/acme = %d, want 200:\n%s", st, body)
866 }
867 if !strings.Contains(body, "pi-1") || !strings.Contains(body, "1.11") {
868 t.Fatalf("joined box pi-1's own reading missing:\n%s", body)
869 }
870 if !strings.Contains(body, "pi-2") {
871 t.Fatalf("unjoined box pi-2 missing from the page entirely:\n%s", body)
872 }
873 if !strings.Contains(body, "no heartbeat") {
874 t.Fatalf("unjoined box pi-2 did not render as no-heartbeat-yet:\n%s", body)
875 }
876}
877
878// TestFleetPageRotateWithoutCSRFForbidden: the rotate form is CSRF-gated
879// like every other mutation on this console.
880func TestFleetPageRotateWithoutCSRFForbidden(t *testing.T) {
881 fx, token := fleetFixture(t, "code-fleetpage-csrf")
882 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
883 map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
884 t.Fatalf("create fleet = %d %v", st, body)
885 }
886 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
887 map[string]string{"label": "pi-1"}, token); st != http.StatusOK {
888 t.Fatalf("add-box = %d %v", st, body)
889 }
890 c := newBrowser(t)
891 signUp(t, fx, c, "code-fleetpage-csrf-browser")
892 st, _, _ := postForm(t, c, fx.u("/fleets/acme/rotate"), url.Values{"csrf": {"wrong"}, "label": {"pi-1"}})
893 if st != http.StatusForbidden {
894 t.Fatalf("rotate with a bad csrf = %d, want 403", st)
895 }
896}
897
898// TestFleetPageRotateShowsTokenOnceWithWarning: the rotate form's response
899// carries the fresh plaintext exactly once, alongside the same
900// detach-not-rotate warning printFleetToken (fleets.go, the CLI) gives — and
901// the registry's stored hash actually moves to match it, the same tripwire
902// TestFleetRotateBoxChangesHash checks at the API layer.
903func TestFleetPageRotateShowsTokenOnceWithWarning(t *testing.T) {
904 fx, token := fleetFixture(t, "code-fleetpage-rotate")
905 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
906 map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
907 t.Fatalf("create fleet = %d %v", st, body)
908 }
909 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
910 map[string]string{"label": "pi-1"}, token); st != http.StatusOK {
911 t.Fatalf("add-box = %d %v", st, body)
912 }
913 c := newBrowser(t)
914 signUp(t, fx, c, "code-fleetpage-rotate-browser")
915 csrf := sessionToken(t, fx, c)
916
917 st, hdr, body := postFormHeaders(t, c, fx.u("/fleets/acme/rotate"), url.Values{"csrf": {csrf}, "label": {"pi-1"}})
918 if st != http.StatusOK {
919 t.Fatalf("rotate = %d, want 200 (rendered directly):\n%s", st, body)
920 }
921 if !strings.Contains(body, "shown once") {
922 t.Fatalf("rotate response missing the shown-once notice:\n%s", body)
923 }
924 if !strings.Contains(strings.ToUpper(body), "NOT REVOCATION") {
925 t.Fatalf("rotate response missing the detach-not-rotate warning:\n%s", body)
926 }
927 // The response body carries a bearer credential — a cached or
928 // bfcache/session-restored copy must never keep it around, the same
929 // reason writeJSON (authcli.go) sets this on every JSON token response.
930 if got := hdr.Get("Cache-Control"); got != "no-store" {
931 t.Fatalf("rotate response Cache-Control = %q, want %q", got, "no-store")
932 }
933
934 reg := openTestReg(t, fx)
935 boxes, err := reg.ListFleetBoxes("acme")
936 if err != nil {
937 t.Fatal(err)
938 }
939 if len(boxes) != 1 {
940 t.Fatalf("boxes = %+v, want exactly pi-1", boxes)
941 }
942 // The rendered token, extracted crudely (it's inside a <code> tag), must
943 // hash to what's actually stored — proving the page shows the SAME token
944 // it wrote, not a decoy.
945 found := false
946 for _, hexTok := range strings.Fields(strings.ReplaceAll(strings.ReplaceAll(body, "<code>", " "), "</code>", " ")) {
947 if len(hexTok) != 64 {
948 continue
949 }
950 sum := sha256.Sum256([]byte(hexTok))
951 if hex.EncodeToString(sum[:]) == boxes[0].TokenHash {
952 found = true
953 break
954 }
955 }
956 if !found {
957 t.Fatalf("no 64-hex token in the rotate response hashes to the stored TokenHash %s:\n%s", boxes[0].TokenHash, body)
958 }
959}
960
961// TestFleetPageTagsChipsRender: a box carrying tags renders each one as its
962// own chip, both on the owner's page and a member's — chips are read-only
963// display, not a mutation surface, so they belong to the same body a member
964// already sees the rest of the row in (TestFleetPageOwnerControlsMemberReadOnly).
965// An untagged box renders the em-dash placeholder instead of an empty cell.
966func TestFleetPageTagsChipsRender(t *testing.T) {
967 fx, token := fleetFixture(t, "code-fleetpage-tags-chips")
968 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
969 map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
970 t.Fatalf("create fleet = %d %v", st, body)
971 }
972 for _, label := range []string{"pi-1", "pi-2"} {
973 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
974 map[string]string{"label": label}, token); st != http.StatusOK {
975 t.Fatalf("add-box %s = %d %v", label, st, body)
976 }
977 }
978 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes/pi-1/tags",
979 map[string]string{"tags": "office,free"}, token); st != http.StatusOK {
980 t.Fatalf("tag-box = %d %v", st, body)
981 }
982
983 c := newBrowser(t)
984 signUp(t, fx, c, "code-fleetpage-tags-chips-browser")
985 st, _, body := get(t, c, fx.u("/fleets/acme"))
986 if st != http.StatusOK {
987 t.Fatalf("GET /fleets/acme = %d, want 200:\n%s", st, body)
988 }
989 for _, want := range []string{`<span class="chip chip-env">office</span>`, `<span class="chip chip-env">free</span>`} {
990 if !strings.Contains(body, want) {
991 t.Fatalf("tagged box missing chip %q in:\n%s", want, body)
992 }
993 }
994 // pi-2 carries no tags: an em-dash, never an empty cell nor pi-1's chips
995 // leaking onto its row.
996 if !strings.Contains(body, "pi-2") {
997 t.Fatalf("untagged box pi-2 missing from the page:\n%s", body)
998 }
999
1000 // A member sees the same chips (read-only display, no gate on it) but
1001 // none of the tag form's own markup.
1002 if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
1003 t.Fatal(err)
1004 }
1005 fx.setUser("sam@keymail.dev")
1006 member := newBrowser(t)
1007 signUp(t, fx, member, "code-fleetpage-tags-chips-member")
1008 memberStatus, _, memberBody := get(t, member, fx.u("/fleets/acme"))
1009 if memberStatus != http.StatusOK {
1010 t.Fatalf("member GET /fleets/acme = %d, want 200:\n%s", memberStatus, memberBody)
1011 }
1012 for _, want := range []string{`<span class="chip chip-env">office</span>`, `<span class="chip chip-env">free</span>`} {
1013 if !strings.Contains(memberBody, want) {
1014 t.Fatalf("member view missing chip %q in:\n%s", want, memberBody)
1015 }
1016 }
1017 if strings.Contains(memberBody, "/fleets/acme/tags") {
1018 t.Fatalf("member fleet page renders the tag mutation form it must not have:\n%s", memberBody)
1019 }
1020}
1021
1022// TestFleetPageTagBoxOwnerUpdatesAndRedirects: the owner's Tag form performs
1023// a full-replace write (same as handleAPIFleetTagBox) and redirects back to
1024// the fleet page with an ordinary ok flash — no secret involved, unlike
1025// rotate, so there is no reason for this to render directly the way
1026// handleFleetRotateBoxPage's response does.
1027func TestFleetPageTagBoxOwnerUpdatesAndRedirects(t *testing.T) {
1028 fx, token := fleetFixture(t, "code-fleetpage-tags-owner")
1029 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
1030 map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
1031 t.Fatalf("create fleet = %d %v", st, body)
1032 }
1033 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
1034 map[string]string{"label": "pi-1"}, token); st != http.StatusOK {
1035 t.Fatalf("add-box = %d %v", st, body)
1036 }
1037 c := newBrowser(t)
1038 signUp(t, fx, c, "code-fleetpage-tags-owner-browser")
1039 csrf := sessionToken(t, fx, c)
1040
1041 status, loc, _ := postForm(t, c, fx.u("/fleets/acme/tags"),
1042 url.Values{"csrf": {csrf}, "label": {"pi-1"}, "tags": {"office,free"}})
1043 if status != http.StatusSeeOther || loc != fx.path("/fleets/acme") {
1044 t.Fatalf("tag-box = %d -> %q, want 303 -> %q", status, loc, fx.path("/fleets/acme"))
1045 }
1046
1047 reg := openTestReg(t, fx)
1048 boxes, err := reg.ListFleetBoxes("acme")
1049 if err != nil {
1050 t.Fatal(err)
1051 }
1052 if len(boxes) != 1 || boxes[0].Tags != "office,free" {
1053 t.Fatalf("registry boxes = %+v, want tags office,free", boxes)
1054 }
1055
1056 _, _, body := get(t, c, fx.u("/fleets/acme"))
1057 if !strings.Contains(body, "Tagged acme/pi-1.") {
1058 t.Fatalf("no tag flash after redirect:\n%s", body)
1059 }
1060 if !strings.Contains(body, `<span class="chip chip-env">office</span>`) {
1061 t.Fatalf("new tags did not render as chips after the redirect:\n%s", body)
1062 }
1063}
1064
1065// TestFleetPageTagBoxMemberGetsSameRefusalAsRotate: a plain member posting
1066// the tag form gets the exact same refusal ownerFleet already gives rotate
1067// and detach — the gate is s.owner (role != RoleOwner -> 404, matching a
1068// non-member's genuinely-unrouted 404), never a distinguishable 403.
1069func TestFleetPageTagBoxMemberGetsSameRefusalAsRotate(t *testing.T) {
1070 fx, token := fleetFixture(t, "code-fleetpage-tags-member")
1071 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
1072 map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
1073 t.Fatalf("create fleet = %d %v", st, body)
1074 }
1075 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
1076 map[string]string{"label": "pi-1"}, token); st != http.StatusOK {
1077 t.Fatalf("add-box = %d %v", st, body)
1078 }
1079 if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
1080 t.Fatal(err)
1081 }
1082 fx.setUser("sam@keymail.dev")
1083 c := newBrowser(t)
1084 signUp(t, fx, c, "code-fleetpage-tags-member-browser")
1085 csrf := sessionToken(t, fx, c)
1086
1087 tagStatus, _, tagBody := postForm(t, c, fx.u("/fleets/acme/tags"),
1088 url.Values{"csrf": {csrf}, "label": {"pi-1"}, "tags": {"office"}})
1089 rotStatus, _, rotBody := postForm(t, c, fx.u("/fleets/acme/rotate"),
1090 url.Values{"csrf": {csrf}, "label": {"pi-1"}})
1091 if tagStatus != http.StatusNotFound {
1092 t.Fatalf("member tag-box = %d, want 404", tagStatus)
1093 }
1094 if tagStatus != rotStatus || tagBody != rotBody {
1095 t.Fatalf("member tag-box refusal (%d %q) not byte-identical to rotate's (%d %q)",
1096 tagStatus, tagBody, rotStatus, rotBody)
1097 }
1098
1099 reg := openTestReg(t, fx)
1100 boxes, err := reg.ListFleetBoxes("acme")
1101 if err != nil {
1102 t.Fatal(err)
1103 }
1104 if boxes[0].Tags != "" {
1105 t.Fatalf("member's refused tag POST still wrote tags: %+v", boxes)
1106 }
1107}
1108
1109// TestFleetPageTagBoxWithoutCSRFForbidden: the tag form is CSRF-gated like
1110// every other mutation on this console — the same 403 rotate's CSRF check
1111// gives (TestFleetPageRotateWithoutCSRFForbidden).
1112func TestFleetPageTagBoxWithoutCSRFForbidden(t *testing.T) {
1113 fx, token := fleetFixture(t, "code-fleetpage-tags-csrf")
1114 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
1115 map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
1116 t.Fatalf("create fleet = %d %v", st, body)
1117 }
1118 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
1119 map[string]string{"label": "pi-1"}, token); st != http.StatusOK {
1120 t.Fatalf("add-box = %d %v", st, body)
1121 }
1122 c := newBrowser(t)
1123 signUp(t, fx, c, "code-fleetpage-tags-csrf-browser")
1124 st, _, _ := postForm(t, c, fx.u("/fleets/acme/tags"), url.Values{"csrf": {"wrong"}, "label": {"pi-1"}, "tags": {"office"}})
1125 if st != http.StatusForbidden {
1126 t.Fatalf("tag-box with a bad csrf = %d, want 403", st)
1127 }
1128}
1129
1130// TestFleetPageTagBoxBadGrammarFlashesErrorNothingWritten: reject, don't
1131// sanitize — invalid grammar (uppercase, here) never reaches SetFleetBoxTags,
1132// the same "checked before the write, not after" posture
1133// handleAPIFleetTagBox already takes, and the flash copy matches Task 2's
1134// API error text verbatim.
1135func TestFleetPageTagBoxBadGrammarFlashesErrorNothingWritten(t *testing.T) {
1136 fx, token := fleetFixture(t, "code-fleetpage-tags-badgrammar")
1137 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
1138 map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
1139 t.Fatalf("create fleet = %d %v", st, body)
1140 }
1141 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
1142 map[string]string{"label": "pi-1"}, token); st != http.StatusOK {
1143 t.Fatalf("add-box = %d %v", st, body)
1144 }
1145 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes/pi-1/tags",
1146 map[string]string{"tags": "office"}, token); st != http.StatusOK {
1147 t.Fatalf("seed tag-box = %d %v", st, body)
1148 }
1149 c := newBrowser(t)
1150 signUp(t, fx, c, "code-fleetpage-tags-badgrammar-browser")
1151 csrf := sessionToken(t, fx, c)
1152
1153 status, loc, _ := postForm(t, c, fx.u("/fleets/acme/tags"),
1154 url.Values{"csrf": {csrf}, "label": {"pi-1"}, "tags": {"Office"}})
1155 if status != http.StatusSeeOther || loc != fx.path("/fleets/acme") {
1156 t.Fatalf("bad-grammar tag-box = %d -> %q, want 303 -> %q", status, loc, fx.path("/fleets/acme"))
1157 }
1158
1159 _, _, body := get(t, c, fx.u("/fleets/acme"))
1160 if !strings.Contains(body, "invalid tags — comma-separated [a-z0-9-] (hyphens interior), max 8") {
1161 t.Fatalf("bad-grammar tag-box did not flash the expected error:\n%s", body)
1162 }
1163
1164 reg := openTestReg(t, fx)
1165 boxes, err := reg.ListFleetBoxes("acme")
1166 if err != nil {
1167 t.Fatal(err)
1168 }
1169 if boxes[0].Tags != "office" {
1170 t.Fatalf("registry tags after rejected write = %q, want unchanged office", boxes[0].Tags)
1171 }
1172}
1173
1174// ---- Task 6: the browser placement doors ---------------------------------
1175//
1176// Two forms, one mechanism: the app's Instances tab moves THIS app to any of
1177// the account's fleets (or back to the shared one), and the fleet page moves
1178// any of the account's apps onto THIS fleet. Both call placeAppTarget, so
1179// what these tests pin is the doors' own share — the owner gate, CSRF, the
1180// cross-account 404, the redirect, and that the same routes-and-records
1181// stamping the CLI API's tests already prove really happened.
1182
1183// placeFixture is a console with titogo claimed, two of its routes and one
1184// already-provisioned instance record seeded, and an owned fleet "pi" to
1185// move it onto. Routes are seeded through a second handle BEFORE the console
1186// opens its own (openTestReg's doc comment).
1187func placeFixture(t *testing.T, code string) (*consoleFixture, string) {
1188 t.Helper()
1189 fx, token := fleetFixture(t, code)
1190 now := time.Now()
1191 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
1192 t.Fatal(err)
1193 }
1194 reg := openTestReg(t, fx)
1195 for _, host := range []string{"acme.go.tito.io", "beta.go.tito.io"} {
1196 if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", App: "titogo", Account: fx.sqid}); err != nil {
1197 t.Fatal(err)
1198 }
1199 }
1200 // Region "" throughout: the app starts genuinely unplaced, so "nothing
1201 // moved" and "moved back to the shared fleet" are the same assertion —
1202 // assertPlacement can check one value across all three stores. (The API
1203 // door's own tripwire test covers stamping OVER a pre-existing region.)
1204 rec := provision.Record{Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "titogo",
1205 Account: fx.sqid, Channel: "stable", CreatedAt: now.UTC()}
1206 if err := provision.CreateRecord(context.Background(), fx.rel, rec); err != nil {
1207 t.Fatal(err)
1208 }
1209 if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
1210 map[string]string{"name": "pi", "bucket": "pi-fleet-data"}, token); st != http.StatusOK {
1211 t.Fatalf("create fleet = %d %v", st, body)
1212 }
1213 return fx, token
1214}
1215
1216// assertPlacement is the whole tripwire in one call: the store of record, the
1217// app's routes, and its already-provisioned instance record all agree on
1218// where the app runs. Anything less is the routes-only half-move the API
1219// door's own tests (TestPlaceStampsRoutesAndInstances) exist to refuse.
1220func assertPlacement(t *testing.T, fx *consoleFixture, want string) {
1221 t.Helper()
1222 if got, err := fx.st.AppPlacement(OpsAccountID, "titogo"); err != nil || got != want {
1223 t.Fatalf("store placement = %q %v, want %q", got, err, want)
1224 }
1225 reg := openTestReg(t, fx)
1226 for _, host := range []string{"acme.go.tito.io", "beta.go.tito.io"} {
1227 rt, err := reg.Get(host)
1228 if err != nil || rt == nil {
1229 t.Fatalf("route %s: %v", host, err)
1230 }
1231 if rt.Region != want {
1232 t.Fatalf("route %s Region = %q, want %q", host, rt.Region, want)
1233 }
1234 }
1235 got, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "titogo", "acme.go.tito.io")
1236 if err != nil {
1237 t.Fatal(err)
1238 }
1239 if got.Region != want {
1240 t.Fatalf("instance record Region = %q, want %q", got.Region, want)
1241 }
1242}
1243
1244// TestAppPlacePageMovesAndClears: the owner's Placement form on the app's
1245// Instances tab stamps routes AND records (placeAppTarget's whole job) and
1246// comes back to the tab with a flash saying what happened — then clears the
1247// same way, which is a MOVE BACK to the shared fleet, not a no-op.
1248func TestAppPlacePageMovesAndClears(t *testing.T) {
1249 fx, _ := placeFixture(t, "code-appplace-move")
1250 c := newBrowser(t)
1251 signUp(t, fx, c, "code-appplace-move-browser")
1252 csrf := sessionToken(t, fx, c)
1253
1254 status, loc, _ := postForm(t, c, fx.u("/apps/titogo/place"),
1255 url.Values{"csrf": {csrf}, "target": {"fleet/pi"}})
1256 if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/instances") {
1257 t.Fatalf("place = %d -> %q, want 303 -> %q", status, loc, fx.path("/apps/titogo/instances"))
1258 }
1259 assertPlacement(t, fx, "fleet/pi")
1260
1261 _, _, body := get(t, c, fx.u("/apps/titogo/instances"))
1262 if !strings.Contains(body, "placed on fleet/pi") {
1263 t.Fatalf("no placed flash after the redirect:\n%s", body)
1264 }
1265
1266 status, loc, _ = postForm(t, c, fx.u("/apps/titogo/place"),
1267 url.Values{"csrf": {csrf}, "target": {""}})
1268 if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/instances") {
1269 t.Fatalf("clear = %d -> %q, want 303 -> %q", status, loc, fx.path("/apps/titogo/instances"))
1270 }
1271 assertPlacement(t, fx, "")
1272 _, _, body = get(t, c, fx.u("/apps/titogo/instances"))
1273 if !strings.Contains(body, "back on the shared fleet") {
1274 t.Fatalf("no cleared flash after the redirect:\n%s", body)
1275 }
1276}
1277
1278// TestAppPlacePageMemberRefused: placement is an owner act. A plain member
1279// gets the same byte-identical 404 every other owner-only app POST gives —
1280// never a 403 that would confirm the app is there and the caller merely
1281// under-privileged — and nothing moves.
1282func TestAppPlacePageMemberRefused(t *testing.T) {
1283 fx, _ := placeFixture(t, "code-appplace-member")
1284 if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
1285 t.Fatal(err)
1286 }
1287 fx.setUser("sam@keymail.dev")
1288 c := newBrowser(t)
1289 signUp(t, fx, c, "code-appplace-member-browser")
1290 csrf := sessionToken(t, fx, c)
1291
1292 status, _, body := postForm(t, c, fx.u("/apps/titogo/place"),
1293 url.Values{"csrf": {csrf}, "target": {"fleet/pi"}})
1294 ghostStatus, _, ghostBody := get(t, c, fx.ts.URL+"/definitely-not-a-page")
1295 if status != http.StatusNotFound || status != ghostStatus || body != ghostBody {
1296 t.Fatalf("member place = %d %q, want the unrouted page's %d %q", status, body, ghostStatus, ghostBody)
1297 }
1298 assertPlacement(t, fx, "")
1299}
1300
1301// TestAppPlacePageWithoutCSRFForbidden: the form is CSRF-gated like every
1302// other mutation on this console.
1303func TestAppPlacePageWithoutCSRFForbidden(t *testing.T) {
1304 fx, _ := placeFixture(t, "code-appplace-csrf")
1305 c := newBrowser(t)
1306 signUp(t, fx, c, "code-appplace-csrf-browser")
1307
1308 status, _, _ := postForm(t, c, fx.u("/apps/titogo/place"),
1309 url.Values{"csrf": {"wrong"}, "target": {"fleet/pi"}})
1310 if status != http.StatusForbidden {
1311 t.Fatalf("place with a bad csrf = %d, want 403", status)
1312 }
1313 assertPlacement(t, fx, "")
1314}
1315
1316// TestAppPlacePageForeignFleetIsIdenticalNotFound: the browser twin of
1317// TestPlaceRefusesForeignFleet. A target naming a fleet ANOTHER account owns
1318// must answer exactly what an unrouted URL does — fleet names are one flat,
1319// cross-account namespace, so a distinguishable refusal here would be a
1320// cross-account existence oracle.
1321func TestAppPlacePageForeignFleetIsIdenticalNotFound(t *testing.T) {
1322 fx, _ := placeFixture(t, "code-appplace-foreign")
1323 now := time.Now()
1324 if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil {
1325 t.Fatal(err)
1326 }
1327 bobToken := cliToken(t, fx, "bob@keymail.dev", "code-appplace-foreign-bob")
1328 if st, body := postJSON(t, fleetsURL(fx, "cdef")+"/new",
1329 map[string]string{"name": "widgets", "bucket": "bob-data"}, bobToken); st != http.StatusOK {
1330 t.Fatalf("bob's fleet create = %d %v", st, body)
1331 }
1332
1333 fx.setUser("paul@keymail.dev")
1334 c := newBrowser(t)
1335 signUp(t, fx, c, "code-appplace-foreign-browser")
1336 csrf := sessionToken(t, fx, c)
1337
1338 status, _, body := postForm(t, c, fx.u("/apps/titogo/place"),
1339 url.Values{"csrf": {csrf}, "target": {"fleet/widgets"}})
1340 ghostStatus, _, ghostBody := get(t, c, fx.ts.URL+"/definitely-not-a-page")
1341 if status != http.StatusNotFound || status != ghostStatus || body != ghostBody {
1342 t.Fatalf("place onto a foreign fleet = %d %q, want the unrouted page's %d %q",
1343 status, body, ghostStatus, ghostBody)
1344 }
1345 assertPlacement(t, fx, "")
1346}
1347
1348// TestAppInstancesPlacementSection: the tab states where the app runs for
1349// everyone, and offers the move only to the owner — with the
1350// data-does-not-move warning beside the form, because "move" is the one word
1351// a customer could reasonably read as "take my data with it".
1352func TestAppInstancesPlacementSection(t *testing.T) {
1353 fx, _ := placeFixture(t, "code-appplace-section")
1354 if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
1355 t.Fatal(err)
1356 }
1357 owner := newBrowser(t)
1358 signUp(t, fx, owner, "code-appplace-section-owner")
1359 st, _, body := get(t, owner, fx.u("/apps/titogo/instances"))
1360 if st != http.StatusOK {
1361 t.Fatalf("GET instances = %d:\n%s", st, body)
1362 }
1363 for _, want := range []string{
1364 "<h2>Placement</h2>",
1365 "This app runs on the shared fleet.",
1366 `action="` + fx.path("/apps/titogo/place") + `"`,
1367 `<option value="fleet/pi"`,
1368 // The D6 warning, rendered across two source lines — matched in the
1369 // two halves the template actually emits (and stopped short of the
1370 // apostrophe html/template escapes).
1371 "Data does not move:",
1372 "hibernated instance state stays in the old placement",
1373 } {
1374 if !strings.Contains(body, want) {
1375 t.Fatalf("owner instances tab missing %q in:\n%s", want, body)
1376 }
1377 }
1378
1379 fx.setUser("sam@keymail.dev")
1380 member := newBrowser(t)
1381 signUp(t, fx, member, "code-appplace-section-member")
1382 st, _, memberBody := get(t, member, fx.u("/apps/titogo/instances"))
1383 if st != http.StatusOK {
1384 t.Fatalf("member GET instances = %d:\n%s", st, memberBody)
1385 }
1386 if !strings.Contains(memberBody, "This app runs on the shared fleet.") {
1387 t.Fatalf("member instances tab lost the placement statement:\n%s", memberBody)
1388 }
1389 if strings.Contains(memberBody, fx.path("/apps/titogo/place")) {
1390 t.Fatalf("member instances tab renders the move form it must not have:\n%s", memberBody)
1391 }
1392}
1393
1394// TestAppInstancesPlacementShowsFleet: once placed, the tab names the fleet
1395// and links to its page, and the form's current fleet is the selected option
1396// rather than a form that reads as "unplaced" over a placed app.
1397func TestAppInstancesPlacementShowsFleet(t *testing.T) {
1398 fx, token := placeFixture(t, "code-appplace-shows")
1399 if st, body := postJSON(t, placeURL(fx, "titogo"), map[string]string{"target": "fleet/pi"}, token); st != http.StatusOK {
1400 t.Fatalf("place = %d %v", st, body)
1401 }
1402 c := newBrowser(t)
1403 signUp(t, fx, c, "code-appplace-shows-browser")
1404 st, _, body := get(t, c, fx.u("/apps/titogo/instances"))
1405 if st != http.StatusOK {
1406 t.Fatalf("GET instances = %d:\n%s", st, body)
1407 }
1408 if !strings.Contains(body, "/fleets/pi") || !strings.Contains(body, "<code>pi</code>") {
1409 t.Fatalf("placed app's tab does not name and link its fleet:\n%s", body)
1410 }
1411 if !strings.Contains(body, `<option value="fleet/pi" selected>`) {
1412 t.Fatalf("the current fleet is not the form's selected option:\n%s", body)
1413 }
1414}
1415
1416// TestFleetPagePlaceFormPlacesApp: the fleet page's own form is the same move
1417// from the other end — pick one of the account's apps, and it lands here.
1418func TestFleetPagePlaceFormPlacesApp(t *testing.T) {
1419 fx, _ := placeFixture(t, "code-fleetplace-form")
1420 c := newBrowser(t)
1421 signUp(t, fx, c, "code-fleetplace-form-browser")
1422 csrf := sessionToken(t, fx, c)
1423
1424 st, _, body := get(t, c, fx.u("/fleets/pi"))
1425 if st != http.StatusOK {
1426 t.Fatalf("GET fleet page = %d:\n%s", st, body)
1427 }
1428 for _, want := range []string{
1429 `action="` + fx.path("/fleets/pi/place") + `"`,
1430 `<option value="titogo">titogo</option>`,
1431 // The D6 warning, rendered across two source lines — matched in the
1432 // two halves the template actually emits (and stopped short of the
1433 // apostrophe html/template escapes).
1434 "Data does not move:",
1435 "hibernated instance state stays in the old placement",
1436 } {
1437 if !strings.Contains(body, want) {
1438 t.Fatalf("fleet page missing %q in:\n%s", want, body)
1439 }
1440 }
1441
1442 status, loc, _ := postForm(t, c, fx.u("/fleets/pi/place"),
1443 url.Values{"csrf": {csrf}, "app": {"titogo"}})
1444 if status != http.StatusSeeOther || loc != fx.path("/fleets/pi") {
1445 t.Fatalf("fleet place = %d -> %q, want 303 -> %q", status, loc, fx.path("/fleets/pi"))
1446 }
1447 assertPlacement(t, fx, "fleet/pi")
1448
1449 _, _, body = get(t, c, fx.u("/fleets/pi"))
1450 if !strings.Contains(body, "placed on fleet/pi") {
1451 t.Fatalf("no placed flash on the fleet page:\n%s", body)
1452 }
1453 // The app is now among the placed apps, so it is no longer offered as
1454 // something to place HERE.
1455 if strings.Contains(body, `<option value="titogo">titogo</option>`) {
1456 t.Fatalf("an app already placed on this fleet is still offered as placeable:\n%s", body)
1457 }
1458}
1459
1460// TestFleetPagePlaceFormMemberRefused: same gate as the fleet page's other
1461// mutations — a member's POST is byte-identical to rotate's refusal.
1462func TestFleetPagePlaceFormMemberRefused(t *testing.T) {
1463 fx, _ := placeFixture(t, "code-fleetplace-member")
1464 if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
1465 t.Fatal(err)
1466 }
1467 fx.setUser("sam@keymail.dev")
1468 c := newBrowser(t)
1469 signUp(t, fx, c, "code-fleetplace-member-browser")
1470 csrf := sessionToken(t, fx, c)
1471
1472 placeStatus, _, placeBody := postForm(t, c, fx.u("/fleets/pi/place"),
1473 url.Values{"csrf": {csrf}, "app": {"titogo"}})
1474 rotStatus, _, rotBody := postForm(t, c, fx.u("/fleets/pi/rotate"),
1475 url.Values{"csrf": {csrf}, "label": {"pi-1"}})
1476 if placeStatus != http.StatusNotFound || placeStatus != rotStatus || placeBody != rotBody {
1477 t.Fatalf("member fleet place refusal (%d %q) not byte-identical to rotate's (%d %q)",
1478 placeStatus, placeBody, rotStatus, rotBody)
1479 }
1480 assertPlacement(t, fx, "")
1481
1482 // And the form is not even offered on their page.
1483 _, _, body := get(t, c, fx.u("/fleets/pi"))
1484 if strings.Contains(body, fx.path("/fleets/pi/place")) {
1485 t.Fatalf("member fleet page renders the place form it must not have:\n%s", body)
1486 }
1487}
1488
1489// TestFleetPagePlaceFormForeignApp: an app the account does not claim is the
1490// same "no such app here" a mistyped app name gets on any other app door —
1491// the fleet's own ownership having already been proven says nothing about
1492// the app named in the body.
1493func TestFleetPagePlaceFormForeignApp(t *testing.T) {
1494 fx, _ := placeFixture(t, "code-fleetplace-foreignapp")
1495 now := time.Now()
1496 if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil {
1497 t.Fatal(err)
1498 }
1499 if _, err := fx.st.CreateAppIn(42, "bobapp", "bob@keymail.dev", now); err != nil {
1500 t.Fatal(err)
1501 }
1502 c := newBrowser(t)
1503 signUp(t, fx, c, "code-fleetplace-foreignapp-browser")
1504 csrf := sessionToken(t, fx, c)
1505
1506 status, _, _ := postForm(t, c, fx.u("/fleets/pi/place"),
1507 url.Values{"csrf": {csrf}, "app": {"bobapp"}})
1508 if status != http.StatusNotFound {
1509 t.Fatalf("placing another account's app = %d, want 404", status)
1510 }
1511 if got, err := fx.st.AppPlacement(42, "bobapp"); err != nil || got != "" {
1512 t.Fatalf("another account's app placement = %q %v, want untouched", got, err)
1513 }
1514}
1515