package console // The version-target, tags and releases CLI API (apiversion.go, release // versioning spec 2026-08-08). The property that matters most, shared with // every other apicli.go door: a non-member meets the byte-identical 404 an // unknown app gets, on every one of these five routes, before any of the // version/tag machinery is ever consulted. import ( "context" "errors" "net/http" "strings" "testing" "time" "github.com/carlosframework/platform/internal/release" ) // TestAPIVersionTargetGetSet: the get/set door for `carlos version target` // — unset reads as an empty target rather than an error, a set round-trips // with the token's own address as set_by, a non-semver is refused 400, a // target that is not above an existing tag is refused 409 (SetTarget's own // message), and a non-member meets the identical 404 an unknown app gets. func TestAPIVersionTargetGetSet(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-version-target") u := fx.ts.URL + "/api/cli/apps/bab/km/version-target" // Unauthenticated: the same 401 every /api/cli door gives with no bearer. if status, out := getJSON(t, u, ""); status != http.StatusUnauthorized { t.Fatalf("no token = %d %v, want 401", status, out) } // Nothing set yet. status, out := getJSON(t, u, token) if status != http.StatusOK { t.Fatalf("get unset = %d %v, want 200", status, out) } if tgt := str(t, out, "target"); tgt != "" { t.Fatalf("unset target = %q, want empty", tgt) } // Set it. status, out = putJSON(t, u, map[string]string{"target": "0.5.0"}, token) if status != http.StatusOK { t.Fatalf("put 0.5.0 = %d %v, want 200", status, out) } if tgt := str(t, out, "target"); tgt != "0.5.0" { t.Fatalf("put response target = %q, want 0.5.0", tgt) } // Reads back, with set_by naming the token's own address. status, out = getJSON(t, u, token) if status != http.StatusOK { t.Fatalf("get after set = %d %v, want 200", status, out) } if tgt := str(t, out, "target"); tgt != "0.5.0" { t.Fatalf("get target = %q, want 0.5.0", tgt) } if by := str(t, out, "set_by"); by != "paul@keymail.dev" { t.Fatalf("set_by = %q, want paul@keymail.dev", by) } if at := str(t, out, "set_at"); at == "" { t.Fatal("set_at is empty") } // A non-semver target is refused 400. status, out = putJSON(t, u, map[string]string{"target": "nope"}, token) if status != http.StatusBadRequest { t.Fatalf("put non-semver = %d %v, want 400", status, out) } // Cut a tag at 0.5.0, then a re-attempt to (re-)set the target to 0.5.0 // is refused 409 — SetTarget's own "already released" message. ctx := context.Background() if err := release.Ship(ctx, fx.rel, release.OpsAccount, release.Manifest{ Schema: 1, App: "km", Version: "0.5.0-1", Kind: "binary", BuiltAt: "2026-08-08T10:00:00Z", Artifacts: map[string]release.Artifact{"linux-amd64": {File: "km"}}, }, map[string][]byte{"linux-amd64": []byte("bin")}); err != nil { t.Fatal(err) } if _, _, err := release.EnsureTag(ctx, fx.rel, release.OpsAccount, "km", "0.5.0-1", "paul@keymail.dev", now); err != nil { t.Fatal(err) } status, out = putJSON(t, u, map[string]string{"target": "0.5.0"}, token) if status != http.StatusConflict { t.Fatalf("put not-above-tags = %d %v, want 409", status, out) } if msg := str(t, out, "error"); !strings.Contains(msg, "already released as tag") { t.Fatalf("409 body = %q, want it to contain %q", msg, "already released as tag") } // Non-member: byte-identical 404 to an unknown app. eve := cliToken(t, fx, "eve@keymail.dev", "code-version-target-eve") statusMember, bodyMember := getRaw(t, u, eve) statusUnknown, bodyUnknown := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/no-such-app/version-target", token) if statusMember != http.StatusNotFound || statusUnknown != http.StatusNotFound { t.Fatalf("non-member/unknown status = %d/%d, want both 404", statusMember, statusUnknown) } if bodyMember != bodyUnknown { t.Fatalf("non-member body %q != unknown-app body %q — the 404 is not byte-identical", bodyMember, bodyUnknown) } } // brokenTargetStore fails every write to the version-target object — a // bucket outage, not a bad request. Everything else passes through // unchanged, so a GET (which reads a different key) is unaffected. type brokenTargetStore struct{ release.Store } const brokenTargetStoreErr = "AccessDenied: s3://carlos-prod-secret-bucket/version-target request id ABC123" func (b brokenTargetStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { if strings.HasSuffix(key, "version-target.json") { return "", errors.New(brokenTargetStoreErr) } return b.Store.Put(ctx, key, body, ifMatch) } // TestAPIVersionTargetSetStoreFailure: a write that fails because the STORE // is broken — not because the caller sent anything wrong — must not be // reported as a 409 (that status says "your input conflicts with reality", // which is false here), and must not echo the store's own error text (bucket // names, request ids) to the caller. func TestAPIVersionTargetSetStoreFailure(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-version-target-storefail") fx.srv.cfg.Releases = brokenTargetStore{fx.rel} status, out := putJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/version-target", map[string]string{"target": "0.5.0"}, token) if status != http.StatusBadGateway { t.Fatalf("put on a broken store = %d %v, want 502", status, out) } if msg := str(t, out, "error"); strings.Contains(msg, "AccessDenied") || strings.Contains(msg, "carlos-prod-secret-bucket") { t.Fatalf("502 body leaks the store's own error text: %q", msg) } } // TestAPIVersionTargetSetNonMemberByteIdentical404: the PUT door is the one // handler in this file with a second gate after apiAccountApp — the write // budget (handleAPIVersionTargetSet's s.cli.write.allow) — and that gate must // never run, let alone answer, before membership is settled: a non-member's // PUT has to meet the exact byte-identical 404 an unknown app's PUT gets, // the same property TestAPIVersionTargetGetSet already pins for the GET door. // // Mutation-mindedness: move the write-budget check ahead of apiAccountApp (or // give it its own error body) and this is the test that notices — a // non-member would get a 429 or a differently-worded 404 instead of the // account-existence-blind refusal every other apicli.go door gives. func TestAPIVersionTargetSetNonMemberByteIdentical404(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil { t.Fatal(err) } eve := cliToken(t, fx, "eve@keymail.dev", "code-version-target-put-eve") token := cliToken(t, fx, "paul@keymail.dev", "code-version-target-put-member") body := map[string]string{"target": "0.5.0"} statusMember, bodyMember := putRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/version-target", body, eve) statusUnknown, bodyUnknown := putRaw(t, fx.ts.URL+"/api/cli/apps/bab/no-such-app/version-target", body, token) if statusMember != http.StatusNotFound || statusUnknown != http.StatusNotFound { t.Fatalf("non-member/unknown status = %d/%d, want both 404", statusMember, statusUnknown) } if bodyMember != bodyUnknown { t.Fatalf("non-member body %q != unknown-app body %q — the 404 is not byte-identical", bodyMember, bodyUnknown) } } // TestAPITagsListAndGet: the tags list omits the changelog, semver-descending // (0.10.0 above 0.6.0, which string order gets wrong); the single-tag door // carries the full changelog; a version never tagged is a 404. func TestAPITagsListAndGet(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-tags-list") ctx := context.Background() ship := func(version, label string) { t.Helper() if err := release.Ship(ctx, fx.rel, release.OpsAccount, release.Manifest{ Schema: 1, App: "km", Version: version, Kind: "binary", BuiltAt: "2026-08-08T10:00:00Z", Label: label, Artifacts: map[string]release.Artifact{"linux-amd64": {File: "km"}}, }, map[string][]byte{"linux-amd64": []byte("bin " + version)}); err != nil { t.Fatal(err) } } ship("0.6.0-1", "six") if _, _, err := release.EnsureTag(ctx, fx.rel, release.OpsAccount, "km", "0.6.0-1", "paul@keymail.dev", now); err != nil { t.Fatal(err) } ship("0.10.0-1", "ten") if _, _, err := release.EnsureTag(ctx, fx.rel, release.OpsAccount, "km", "0.10.0-1", "paul@keymail.dev", now); err != nil { t.Fatal(err) } status, listOut := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/tags", token) if status != http.StatusOK { t.Fatalf("list tags = %d, want 200", status) } tags, _ := listOut["tags"].([]any) if len(tags) != 2 { t.Fatalf("list tags = %d entries, want 2: %v", len(tags), listOut) } first, _ := tags[0].(map[string]any) second, _ := tags[1].(map[string]any) if first["version"] != "0.10.0" || second["version"] != "0.6.0" { t.Fatalf("order = %v then %v, want 0.10.0 then 0.6.0 (semver-desc, not string order)", first["version"], second["version"]) } if _, has := first["changelog"]; has { t.Fatalf("list row carries a changelog key: %v", first) } status, oneOut := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/tags/0.6.0", token) if status != http.StatusOK { t.Fatalf("get tag = %d %v, want 200", status, oneOut) } if cl := str(t, oneOut, "changelog"); cl == "" { t.Fatalf("get tag changelog is empty: %v", oneOut) } status, missOut := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/tags/9.9.9", token) if status != http.StatusNotFound { t.Fatalf("get missing tag = %d %v, want 404", status, missOut) } // A version that isn't a bare semver at all — including a path-traversal // attempt — is refused before the key builder ever runs: ListTags only // ever produces semver-parsable names, so neither string could ever name // a real tag, on any backend. status, traversalOut := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/tags/..%2Fversion-target", token) if status != http.StatusNotFound { t.Fatalf("get tags/..%%2Fversion-target = %d %v, want 404", status, traversalOut) } status, nonSemverOut := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/tags/not-semver", token) if status != http.StatusNotFound { t.Fatalf("get tags/not-semver = %d %v, want 404", status, nonSemverOut) } } // TestAPIReleasesList: Versions() joined against ListTags — the release a // tag was cut from carries that tag's name, and every row carries its // manifest's label. func TestAPIReleasesList(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-releases-list") ctx := context.Background() if err := release.Ship(ctx, fx.rel, release.OpsAccount, release.Manifest{ Schema: 1, App: "km", Version: "0.5.0-1", Kind: "binary", BuiltAt: "2026-08-08T10:00:00Z", Label: "fixing modals", Artifacts: map[string]release.Artifact{"linux-amd64": {File: "km"}}, }, map[string][]byte{"linux-amd64": []byte("bin")}); err != nil { t.Fatal(err) } if _, _, err := release.EnsureTag(ctx, fx.rel, release.OpsAccount, "km", "0.5.0-1", "paul@keymail.dev", now); err != nil { t.Fatal(err) } status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/releases", token) if status != http.StatusOK { t.Fatalf("list releases = %d, want 200", status) } rows, _ := out["releases"].([]any) if len(rows) != 1 { t.Fatalf("releases = %d rows, want 1: %v", len(rows), out) } row, _ := rows[0].(map[string]any) if row["version"] != "0.5.0-1" { t.Fatalf("row version = %v, want 0.5.0-1", row["version"]) } if row["label"] != "fixing modals" { t.Fatalf("row label = %v, want %q", row["label"], "fixing modals") } if row["tag"] != "0.5.0" { t.Fatalf("row tag = %v, want 0.5.0", row["tag"]) } } // brokenTagsListStore fails every List against a tags/ prefix — a bucket // outage on the tag read specifically, not on the releases read ListTags's // caller already succeeded at. type brokenTagsListStore struct{ release.Store } const brokenTagsListStoreErr = "AccessDenied: s3://carlos-prod-secret-bucket/tags request id DEF456" func (b brokenTagsListStore) List(ctx context.Context, prefix string) ([]string, error) { if strings.HasSuffix(prefix, "/tags/") { return nil, errors.New(brokenTagsListStoreErr) } return b.Store.List(ctx, prefix) } // TestAPIReleasesListTagsStoreFailure: view.go's tagByRelease (the fetch // form, used here rather than the pure tagIndex fold since this handler has // no tag slice of its own) propagates a ListTags failure as an error rather // than an empty map — this pins that handleAPIReleasesList turns that error // into the same 502 every other broken-store read in this file gets, // rather than a 200 whose every row silently claims no tag exists. func TestAPIReleasesListTagsStoreFailure(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-releases-list-tagsfail") if err := release.Ship(context.Background(), fx.rel, release.OpsAccount, release.Manifest{ Schema: 1, App: "km", Version: "0.5.0-1", Kind: "binary", BuiltAt: "2026-08-08T10:00:00Z", Artifacts: map[string]release.Artifact{"linux-amd64": {File: "km"}}, }, map[string][]byte{"linux-amd64": []byte("bin")}); err != nil { t.Fatal(err) } fx.srv.cfg.Releases = brokenTagsListStore{fx.rel} status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/releases", token) if status != http.StatusBadGateway { t.Fatalf("releases list with a broken tags read = %d %v, want 502", status, out) } if msg := str(t, out, "error"); strings.Contains(msg, "AccessDenied") || strings.Contains(msg, "carlos-prod-secret-bucket") { t.Fatalf("502 body leaks the store's own error text: %q", msg) } }