fleets: tag-box — API door, CLI verb, ls column
POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/tags: owner-bearer
auth via the existing apiFleet gate, full-replace of a box's tag list
(registry.SetFleetBoxTags), grammar-checked client- and server-side with
registry.ValidBoxTags before any write. fleetBoxView/apiFleetBox gain an
additive Tags field (json:"tags,omitempty"). CLI: carlos fleets tag-box
<fleet> <label> -tags <tags> ("" clears), and a TAGS column on fleets ls
(untagged prints "-").
Gate order and refusal shapes mirror rotate-box: apiFleet's byte-identical
unknown/foreign-fleet 404, an unknown label's own "no such box" 404, bad
grammar a 400. Verified the unknown-label 404 is byte-identical to
rotate's own, and the foreign-fleet 404 byte-identical to the plain
canonicalNotFound every other door in this file uses.
go build ./... && go vet ./... clean; go test ./... -count=1 green across
all 21 packages; gofmt -l clean on touched files.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+309
−10
apimode.go+18 −0fleets.go+62 −6fleets_cli_test.go+91 −3internal/console/authcli.go+1 −0internal/console/fleets.go+61 −1internal/console/fleets_test.go+76 −0
diff --git a/apimode.go b/apimode.go| index 340e476c2..a6b106a5c 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -1300,6 +1300,7 @@ func fleetPath(a apiAcct, tail string) string { |
| type apiFleetBox struct { |
| Label string `json:"label"` |
| Status string `json:"status"` |
| + Tags string `json:"tags,omitempty"` |
| } |
| type apiFleetView struct { |
| @@ -1396,6 +1397,23 @@ func (s *apiSession) rotateFleetBoxToken(ctx context.Context, a apiAcct, fleet, |
| return res.Token, nil |
| } |
| +// tagFleetBox replaces a box's tag list IN FULL — `carlos fleets tag-box`. |
| +// tags "" clears every tag; there is no merge form. |
| +func (s *apiSession) tagFleetBox(ctx context.Context, a apiAcct, fleet, label, tags string) error { |
| + in := struct { |
| + Tags string `json:"tags"` |
| + }{tags} |
| + status, body, err := s.postJSON(ctx, |
| + fleetPath(a, "/"+url.PathEscape(fleet)+"/boxes/"+url.PathEscape(label)+"/tags"), in) |
| + if err != nil { |
| + return err |
| + } |
| + if status != http.StatusOK { |
| + return apiErrorFromBody(status, body) |
| + } |
| + return nil |
| +} |
| + |
| // detachFleetBox flips a box to "detached" — the hard revocation rotation is |
| // not: the edge closes an already-connected channel too |
| // (handleAPIFleetDetachBox). `carlos fleets detach-box`. |
diff --git a/fleets.go b/fleets.go| index 604b36a6a..add35edaa 100644 |
| --- a/fleets.go |
| +++ b/fleets.go |
| @@ -27,7 +27,7 @@ import ( |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| -const fleetsUsage = `usage: carlos fleets <create|add-box|rotate-token|detach-box|ls> [flags] |
| +const fleetsUsage = `usage: carlos fleets <create|add-box|rotate-token|tag-box|detach-box|ls> [flags] |
| create -bucket <bucket> [-account <sqid|name>] <name> |
| mint an account-owned fleet: a named group of remote boxes |
| @@ -59,6 +59,13 @@ const fleetsUsage = `usage: carlos fleets <create|add-box|rotate-token|detach-bo |
| is already connected on — a box whose credential may be |
| COMPROMISED needs detach-box, not this. |
| + tag-box -tags <tags> [-account <sqid|name>] <fleet> <label> |
| + replace a box's tags IN FULL: comma-separated segments of |
| + 1-24 lowercase a-z/0-9/hyphen characters each (hyphens |
| + interior only, never leading or trailing), max 8 tags. |
| + -tags "" clears every tag. This is a full replace, not a |
| + merge — the tags sent are exactly the tags that land. |
| + |
| detach-box [-account <sqid|name>] <fleet> <label> |
| the hard revocation rotate-token is not: flips the box to |
| "detached", which refuses its next dial-in AND closes any |
| @@ -66,8 +73,8 @@ const fleetsUsage = `usage: carlos fleets <create|add-box|rotate-token|detach-bo |
| needs. |
| ls [-account <sqid|name>] |
| - list every fleet the account owns, with each box's label |
| - and status. |
| + list every fleet the account owns, with each box's label, |
| + status and tags. |
| -account <sqid|name> account the fleet belongs to (default: ops) — |
| resolved by the console against your own |
| @@ -94,6 +101,8 @@ func RunFleets(args []string) { |
| runFleetsAddBox(args[1:]) |
| case "rotate-token": |
| runFleetsRotateToken(args[1:]) |
| + case "tag-box": |
| + runFleetsTagBox(args[1:]) |
| case "detach-box": |
| runFleetsDetachBox(args[1:]) |
| case "ls": |
| @@ -248,6 +257,49 @@ func printFleetToken(fleet, label, token string, rotated bool) { |
| fmt.Fprintln(stdout, " does NOT evict a live session — a COMPROMISED box needs `fleets detach-box` instead.") |
| } |
| +// runFleetsTagBox implements `carlos fleets tag-box`: a full replace of a |
| +// box's tags, checked client-side with registry.ValidBoxTags BEFORE the API |
| +// call — the same "a shape the server is certain to refuse should cost |
| +// nothing" principle validFleetName/validBoxLabel already state above. |
| +// -tags has no distinct "absent" state from -tags "" (both are the flag's |
| +// zero value); that ambiguity is fine here because "" is itself a valid, |
| +// meaningful value (clear every tag), not an error condition. |
| +func runFleetsTagBox(args []string) { |
| + fs := flag.NewFlagSet("fleets tag-box", flag.ExitOnError) |
| + acctRaw := fs.String("account", "ops", "account (sqid or name) the fleet belongs to") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| + consoleFlag := fs.String("console", "", "console to tag through (default: the only logged-in one)") |
| + tags := fs.String("tags", "", `comma-separated tags, full replace ("" clears every tag)`) |
| + fs.Parse(args) |
| + if fs.NArg() != 2 { |
| + log.Fatal("usage: carlos fleets tag-box -tags <tags> [-account <sqid|name>] [-account-id <sqid>] " + |
| + "[-console <origin>] <fleet> <label>") |
| + } |
| + fleet, label := strings.TrimSpace(fs.Arg(0)), strings.TrimSpace(fs.Arg(1)) |
| + if err := validFleetName(fleet); err != nil { |
| + log.Fatalf("fleets tag-box: %v", err) |
| + } |
| + if err := validBoxLabel(label); err != nil { |
| + log.Fatalf("fleets tag-box: %v", err) |
| + } |
| + tagsV := strings.TrimSpace(*tags) |
| + if !registry.ValidBoxTags(tagsV) { |
| + log.Fatal("fleets tag-box: invalid tags — comma-separated [a-z0-9-] (hyphens interior), max 8") |
| + } |
| + |
| + sess := fleetsSignedInSession(*consoleFlag) |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + if err := sess.tagFleetBox(context.Background(), acct, fleet, label, tagsV); err != nil { |
| + log.Fatalf("fleets tag-box: %v", err) |
| + } |
| + if tagsV == "" { |
| + fmt.Fprintf(stdout, "cleared tags for %s/%s\n", fleet, label) |
| + } else { |
| + fmt.Fprintf(stdout, "tagged %s/%s [%s]\n", fleet, label, tagsV) |
| + } |
| +} |
| + |
| func runFleetsDetachBox(args []string) { |
| fs := flag.NewFlagSet("fleets detach-box", flag.ExitOnError) |
| acctRaw := fs.String("account", "ops", "account (sqid or name) the fleet belongs to") |
| @@ -294,14 +346,18 @@ func runFleetsList(args []string) { |
| return |
| } |
| w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0) |
| - fmt.Fprintln(w, "FLEET\tBUCKET\tBOX\tSTATUS") |
| + fmt.Fprintln(w, "FLEET\tBUCKET\tBOX\tSTATUS\tTAGS") |
| for _, f := range fleets { |
| if len(f.Boxes) == 0 { |
| - fmt.Fprintf(w, "%s\t%s\t-\t-\n", f.Name, f.Bucket) |
| + fmt.Fprintf(w, "%s\t%s\t-\t-\t-\n", f.Name, f.Bucket) |
| continue |
| } |
| for _, b := range f.Boxes { |
| - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", f.Name, f.Bucket, b.Label, b.Status) |
| + tags := b.Tags |
| + if tags == "" { |
| + tags = "-" |
| + } |
| + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", f.Name, f.Bucket, b.Label, b.Status, tags) |
| } |
| } |
| w.Flush() |
diff --git a/fleets_cli_test.go b/fleets_cli_test.go| index b0764f578..970fd412f 100644 |
| --- a/fleets_cli_test.go |
| +++ b/fleets_cli_test.go |
| @@ -225,6 +225,40 @@ func TestAPISessionFleetDoorsSurfaceServerErrors(t *testing.T) { |
| if err := s.detachFleetBox(context.Background(), acct, "ghost", "x"); err == nil { |
| t.Fatal("detachFleetBox against a 404 must error") |
| } |
| + if err := s.tagFleetBox(context.Background(), acct, "ghost", "x", "office"); err == nil { |
| + t.Fatal("tagFleetBox against a 404 must error") |
| + } |
| +} |
| + |
| +// TestAPISessionFleetTagBox: tagFleetBox POSTs to the labelled tags door |
| +// with the tags field in the body (full replace, including the "" clearing |
| +// case), and returns no token — unlike add-box/rotate-token, this door's |
| +// response body carries nothing this client reads. |
| +func TestAPISessionFleetTagBox(t *testing.T) { |
| + var gotPath string |
| + var gotBody struct{ Tags string } |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath = r.URL.Path |
| + json.NewDecoder(r.Body).Decode(&gotBody) |
| + json.NewEncoder(w).Encode(map[string]any{}) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + acct := apiAcct{Seg: "bab"} |
| + |
| + if err := s.tagFleetBox(context.Background(), acct, "acme", "pi-1", "office,free"); err != nil { |
| + t.Fatalf("tagFleetBox: %v", err) |
| + } |
| + if gotPath != "/api/cli/fleets/bab/acme/boxes/pi-1/tags" || gotBody.Tags != "office,free" { |
| + t.Fatalf("tagFleetBox request: path=%s body=%+v", gotPath, gotBody) |
| + } |
| + |
| + if err := s.tagFleetBox(context.Background(), acct, "acme", "pi-1", ""); err != nil { |
| + t.Fatalf("tagFleetBox clear: %v", err) |
| + } |
| + if gotBody.Tags != "" { |
| + t.Fatalf("tagFleetBox clear sent tags=%q, want empty", gotBody.Tags) |
| + } |
| } |
| // TestAPISessionPlace: place POSTs the target in the body to .../place, for |
| @@ -303,6 +337,8 @@ type fakeFleetsConsole struct { |
| lastCreatePlace atomic.Value // string |
| lastPlaceTarget atomic.Value // string |
| lastDetachPath atomic.Value // string |
| + lastTagPath atomic.Value // string |
| + lastTagBody atomic.Value // string |
| } |
| func newFakeFleetsConsole(t *testing.T) *fakeFleetsConsole { |
| @@ -322,7 +358,7 @@ func newFakeFleetsConsole(t *testing.T) *fakeFleetsConsole { |
| json.NewEncoder(w).Encode(map[string]any{ |
| "fleets": []map[string]any{ |
| {"name": "acme", "bucket": "acme-data", "boxes": []map[string]any{ |
| - {"label": "pi-1", "status": "active"}, |
| + {"label": "pi-1", "status": "active", "tags": "office,free"}, |
| {"label": "pi-2", "status": "detached"}, |
| }}, |
| }, |
| @@ -344,6 +380,13 @@ func newFakeFleetsConsole(t *testing.T) *fakeFleetsConsole { |
| fc.lastDetachPath.Store(r.URL.Path) |
| json.NewEncoder(w).Encode(map[string]any{}) |
| }) |
| + mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/tags", func(w http.ResponseWriter, r *http.Request) { |
| + var req struct{ Tags string } |
| + json.NewDecoder(r.Body).Decode(&req) |
| + fc.lastTagPath.Store(r.URL.Path) |
| + fc.lastTagBody.Store(req.Tags) |
| + json.NewEncoder(w).Encode(map[string]any{}) |
| + }) |
| mux.HandleFunc("POST /api/cli/apps/{account}/new", func(w http.ResponseWriter, r *http.Request) { |
| var req struct{ Name, Place string } |
| json.NewDecoder(r.Body).Decode(&req) |
| @@ -500,7 +543,8 @@ func TestRunFleetsDetachBoxHitsRightPath(t *testing.T) { |
| } |
| // TestRunFleetsListPrintsEveryBox: `carlos fleets ls` prints one row per |
| -// box, including a detached one, from the fake console's fixed listing. |
| +// box, including a detached one, from the fake console's fixed listing — |
| +// and a TAGS column: the tagged box's tags verbatim, the untagged box's "-". |
| func TestRunFleetsListPrintsEveryBox(t *testing.T) { |
| fleetsCLIFixture(t) |
| buf := captureStdout(t) |
| @@ -508,11 +552,52 @@ func TestRunFleetsListPrintsEveryBox(t *testing.T) { |
| RunFleets([]string{"ls", "-account", "bab"}) |
| out := buf.String() |
| - for _, want := range []string{"acme", "acme-data", "pi-1", "active", "pi-2", "detached"} { |
| + for _, want := range []string{"acme", "acme-data", "pi-1", "active", "pi-2", "detached", "TAGS", "office,free"} { |
| if !strings.Contains(out, want) { |
| t.Fatalf("fleets ls stdout missing %q:\n%s", want, out) |
| } |
| } |
| + // pi-2 carries no tags — its row must show the placeholder, not a blank |
| + // cell (which whitespace-splitting could not tell apart from a missing |
| + // column). |
| + for _, line := range strings.Split(out, "\n") { |
| + if !strings.Contains(line, "pi-2") { |
| + continue |
| + } |
| + fields := strings.Fields(line) |
| + if len(fields) == 0 || fields[len(fields)-1] != "-" { |
| + t.Fatalf("pi-2 row = %q, want its last (TAGS) field to be the '-' placeholder", line) |
| + } |
| + } |
| +} |
| + |
| +// TestRunFleetsTagBoxHitsRightPathAndClears: `carlos fleets tag-box` PUTs |
| +// (POSTs) the tags in the body to the labelled tags door, full-replace, and |
| +// -tags "" clears — both print their own confirmation, mirroring |
| +// TestRunAppsPlaceHitsRightPathAndClears's two-call shape. |
| +func TestRunFleetsTagBoxHitsRightPathAndClears(t *testing.T) { |
| + fc := fleetsCLIFixture(t) |
| + buf := captureStdout(t) |
| + |
| + RunFleets([]string{"tag-box", "-tags", "office,free", "-account", "bab", "acme", "pi-1"}) |
| + if got := fc.lastTagPath.Load(); got != "/api/cli/fleets/bab/acme/boxes/pi-1/tags" { |
| + t.Fatalf("tag-box path = %v, want the labelled tags door", got) |
| + } |
| + if got := fc.lastTagBody.Load(); got != "office,free" { |
| + t.Fatalf("console saw tags %v, want office,free", got) |
| + } |
| + if !strings.Contains(buf.String(), "tagged acme/pi-1 [office,free]") { |
| + t.Fatalf("stdout = %q, want it to confirm the tags", buf.String()) |
| + } |
| + |
| + buf2 := captureStdout(t) |
| + RunFleets([]string{"tag-box", "-tags", "", "-account", "bab", "acme", "pi-1"}) |
| + if got := fc.lastTagBody.Load(); got != "" { |
| + t.Fatalf("console saw tags %v, want \"\" (cleared)", got) |
| + } |
| + if !strings.Contains(buf2.String(), "cleared tags for acme/pi-1") { |
| + t.Fatalf("stdout = %q, want the cleared confirmation", buf2.String()) |
| + } |
| } |
| // TestRunAppsCreateWithPlaceSendsPlaceField: `carlos apps create -place |
| @@ -594,6 +679,9 @@ func TestFleetsAndPlaceCLIUsageErrors(t *testing.T) { |
| {"fleets add-box missing label", []string{"fleets", "add-box", "acme"}, "usage: carlos fleets add-box"}, |
| {"fleets add-box bad label", []string{"fleets", "add-box", "acme", "Bad Label"}, "not a usable box label"}, |
| {"fleets rotate-token no args", []string{"fleets", "rotate-token"}, "usage: carlos fleets rotate-token"}, |
| + {"fleets tag-box no args", []string{"fleets", "tag-box"}, "usage: carlos fleets tag-box"}, |
| + {"fleets tag-box bad tags grammar", []string{"fleets", "tag-box", "-tags", "Office", "acme", "pi-1"}, "invalid tags"}, |
| + {"fleets tag-box bad fleet name", []string{"fleets", "tag-box", "-tags", "office", "Bad Name", "pi-1"}, "not a usable fleet name"}, |
| {"fleets detach-box no args", []string{"fleets", "detach-box"}, "usage: carlos fleets detach-box"}, |
| {"apps place missing app", []string{"apps", "place", "-target", "fleet/pi"}, "usage: carlos apps place"}, |
| {"apps place bad target shape", []string{"apps", "place", "-app", "titogo", "-target", "eu"}, "not a usable placement target"}, |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index b42d796c3..c8b1df118 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -287,6 +287,7 @@ func (s *Server) registerCLIRoutes() { |
| s.mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes", s.handleAPIFleetAddBox) |
| s.mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/rotate", s.handleAPIFleetRotateBox) |
| s.mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/detach", s.handleAPIFleetDetachBox) |
| + s.mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/tags", s.handleAPIFleetTagBox) |
| // Placement lives under the apps family, not fleets: its subject is an |
| // APP, and a target fleet name is only ever the request body's data. |
| s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/place", s.handleAPIPlace) |
diff --git a/internal/console/fleets.go b/internal/console/fleets.go| index 50d191d08..b15dc9df9 100644 |
| --- a/internal/console/fleets.go |
| +++ b/internal/console/fleets.go |
| @@ -49,6 +49,10 @@ import ( |
| type fleetBoxView struct { |
| Label string `json:"label"` |
| Status string `json:"status"` |
| + // Tags is additive (2026-08-10, Task 2): omitted entirely for an |
| + // untagged box, never a bare "" — existing callers parsing this shape |
| + // before tags existed see nothing new unless a box actually carries some. |
| + Tags string `json:"tags,omitempty"` |
| } |
| type fleetView struct { |
| @@ -238,7 +242,7 @@ func (s *Server) handleAPIFleetList(w http.ResponseWriter, r *http.Request) { |
| } |
| bv := make([]fleetBoxView, 0, len(boxes)) |
| for _, b := range boxes { |
| - bv = append(bv, fleetBoxView{Label: b.Label, Status: b.Status}) |
| + bv = append(bv, fleetBoxView{Label: b.Label, Status: b.Status, Tags: b.Tags}) |
| } |
| out = append(out, fleetView{Name: f.Name, Bucket: f.Bucket, Boxes: bv}) |
| } |
| @@ -349,6 +353,62 @@ func (s *Server) handleAPIFleetRotateBox(w http.ResponseWriter, r *http.Request) |
| writeJSON(w, http.StatusOK, map[string]any{"token": plaintext}) |
| } |
| +// handleAPIFleetTagBox answers `carlos fleets tag-box`: a FULL REPLACE of a |
| +// box's tag list, never a merge — an empty "tags" clears every tag rather |
| +// than being refused as a no-op, matching SetFleetBoxTags's own plain |
| +// overwrite (`UPDATE ... SET tags = ?`, registry/fleets.go): whatever this |
| +// door validates and passes through is exactly what lands. |
| +// |
| +// Grammar (registry.ValidBoxTags) is checked here, before the registry |
| +// write ever runs — the same "a shape the writer is certain to refuse |
| +// should never reach it" principle handleAPIFleetNew's name/bucket checks |
| +// and handleAPIFleetAddBox's label check already follow. The gate order and |
| +// refusal shapes below deliberately match rotate's: apiFleet's byte- |
| +// identical unknown/foreign-fleet 404 first, then an unknown LABEL within |
| +// an already-owned fleet is its own ordinary 404 ("no such box") — see |
| +// findFleetBox's doc comment for why that one is not the cross-tenant-probe |
| +// 404. |
| +func (s *Server) handleAPIFleetTagBox(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, fleet, reg, ok := s.apiFleet(w, r) |
| + if !ok { |
| + return |
| + } |
| + if !s.cli.write.allow(addr, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + return |
| + } |
| + var req struct { |
| + Tags string `json:"tags"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + tags := strings.TrimSpace(req.Tags) |
| + if !registry.ValidBoxTags(tags) { |
| + jsonError(w, http.StatusBadRequest, |
| + "invalid tags — comma-separated [a-z0-9-] (hyphens interior), max 8") |
| + return |
| + } |
| + label := r.PathValue("label") |
| + box, err := findFleetBox(reg, fleet.Name, label) |
| + if err != nil { |
| + log.Printf("console: fleet tag-box %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) |
| + jsonError(w, http.StatusInternalServerError, "could not read the box") |
| + return |
| + } |
| + if box == nil { |
| + jsonError(w, http.StatusNotFound, "no such box") |
| + return |
| + } |
| + if err := reg.SetFleetBoxTags(fleet.Name, label, tags); err != nil { |
| + log.Printf("console: fleet tag-box %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) |
| + jsonError(w, http.StatusInternalServerError, "could not tag the box — try again") |
| + return |
| + } |
| + s.audit(acct, "", addr, "fleet", "tagged "+fleet.Name+"/"+label+" ["+tags+"] (cli)") |
| + writeJSON(w, http.StatusOK, map[string]any{}) |
| +} |
| + |
| // handleAPIFleetDetachBox answers `carlos fleets detach-box`: flips a box to |
| // "detached", which the edge treats as an outright refusal (a dial-in is |
| // refused, and — unlike rotation — an already-connected channel is closed). |
diff --git a/internal/console/fleets_test.go b/internal/console/fleets_test.go| index 860351ca6..28bda9805 100644 |
| --- a/internal/console/fleets_test.go |
| +++ b/internal/console/fleets_test.go |
| @@ -220,6 +220,82 @@ func TestFleetDetachBoxFlipsStatus(t *testing.T) { |
| } |
| } |
| +// TestAPIFleetTagBox: a full-replace tag write lands in the registry and |
| +// shows up on the list door's "tags" field; bad grammar 400s; an unknown |
| +// label 404s byte-identical to rotate's own unknown-label 404 (both are |
| +// jsonError's "no such box", not the plain apiFleet 404); a foreign-owned |
| +// fleet 404s the ordinary apiFleet way (byte-identical to canonicalNotFound, |
| +// same as every other door in TestFleetCrossAccountAccess404). |
| +func TestAPIFleetTagBox(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-fleet-tagbox") |
| + if status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "pi", "bucket": "pi-data"}, token); status != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", status, body) |
| + } |
| + if status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/pi/boxes", map[string]string{"label": "box-a"}, token); status != http.StatusOK { |
| + t.Fatalf("add-box = %d %v", status, body) |
| + } |
| + |
| + status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/pi/boxes/box-a/tags", |
| + map[string]string{"tags": "office,free"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("tag-box = %d %v, want 200", status, body) |
| + } |
| + |
| + reg := openTestReg(t, fx) |
| + boxes, err := reg.ListFleetBoxes("pi") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(boxes) != 1 || boxes[0].Tags != "office,free" { |
| + t.Fatalf("registry boxes = %+v, want tags office,free", boxes) |
| + } |
| + |
| + _, listBody := getJSON(t, fleetsURL(fx, fx.sqid), token) |
| + fleets := listBody["fleets"].([]any) |
| + f := fleets[0].(map[string]any) |
| + bx := f["boxes"].([]any)[0].(map[string]any) |
| + if bx["tags"] != "office,free" { |
| + t.Fatalf("listed box tags = %v, want office,free", bx["tags"]) |
| + } |
| + |
| + // Bad grammar (uppercase) is a 400, and must not have touched the row. |
| + status, body = postJSON(t, fleetsURL(fx, fx.sqid)+"/pi/boxes/box-a/tags", |
| + map[string]string{"tags": "Office"}, token) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("bad-grammar tag-box = %d %v, want 400", status, body) |
| + } |
| + boxes, err = reg.ListFleetBoxes("pi") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if boxes[0].Tags != "office,free" { |
| + t.Fatalf("registry tags after rejected write = %q, want unchanged office,free", boxes[0].Tags) |
| + } |
| + |
| + // Unknown label: byte-identical to rotate's own unknown-label 404. |
| + rotStatus, rotHeader, rotBody := rawReq(t, http.MethodPost, fleetsURL(fx, fx.sqid)+"/pi/boxes/ghost/rotate", token, `{}`) |
| + tagStatus, tagHeader, tagBody := rawReq(t, http.MethodPost, fleetsURL(fx, fx.sqid)+"/pi/boxes/ghost/tags", token, `{"tags":"x"}`) |
| + if tagStatus != http.StatusNotFound { |
| + t.Fatalf("tag-box unknown label = %d, want 404", tagStatus) |
| + } |
| + if tagStatus != rotStatus || tagBody != rotBody || tagHeader.Get("Content-Type") != rotHeader.Get("Content-Type") { |
| + t.Fatalf("tag-box unknown-label 404 = %d %q (%s), want byte-identical to rotate's %d %q (%s)", |
| + tagStatus, tagBody, tagHeader.Get("Content-Type"), rotStatus, rotBody, rotHeader.Get("Content-Type")) |
| + } |
| + |
| + // Foreign-owned fleet: the ordinary apiFleet 404, same as every other |
| + // door in TestFleetCrossAccountAccess404. |
| + now := time.Now() |
| + if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + bobToken := cliToken(t, fx, "bob@keymail.dev", "code-fleet-tagbox-bob") |
| + want := canonicalNotFound(t) |
| + fStatus, fHeader, fOut := rawReq(t, http.MethodPost, fleetsURL(fx, "cdef")+"/pi/boxes/box-a/tags", bobToken, `{"tags":"x"}`) |
| + assertNotFound(t, "tag-box on a foreign-owned fleet", want, fStatus, fHeader, fOut) |
| +} |
| + |
| // TestFleetCrossAccountAccess404: a fleet named under the caller's OWN |
| // account segment but owned by a DIFFERENT account resolves the exact same |
| // bytes an outright nonexistent fleet name does — fleet names are one flat |