carlos / platform Public

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>
Paul Campbell dfdd3fc0f1be2d46c123114cd8cafd98d18abab7 parent 3117197
6 files changed, +309 −10
  • apimode.go +18 −0
  • fleets.go +62 −6
  • fleets_cli_test.go +91 −3
  • internal/console/authcli.go +1 −0
  • internal/console/fleets.go +61 −1
  • internal/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