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 version-target, tags and releases CLI API (apiversion.go, release
4// versioning spec 2026-08-08). The property that matters most, shared with
5// every other apicli.go door: a non-member meets the byte-identical 404 an
6// unknown app gets, on every one of these five routes, before any of the
7// version/tag machinery is ever consulted.
8
9import (
10 "context"
11 "errors"
12 "net/http"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/carlosframework/platform/internal/release"
18)
19
20// TestAPIVersionTargetGetSet: the get/set door for `carlos version target`
21// — unset reads as an empty target rather than an error, a set round-trips
22// with the token's own address as set_by, a non-semver is refused 400, a
23// target that is not above an existing tag is refused 409 (SetTarget's own
24// message), and a non-member meets the identical 404 an unknown app gets.
25func TestAPIVersionTargetGetSet(t *testing.T) {
26 fx := newConsoleFixture(t)
27 now := time.Now()
28 if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil {
29 t.Fatal(err)
30 }
31 token := cliToken(t, fx, "paul@keymail.dev", "code-version-target")
32 u := fx.ts.URL + "/api/cli/apps/bab/km/version-target"
33
34 // Unauthenticated: the same 401 every /api/cli door gives with no bearer.
35 if status, out := getJSON(t, u, ""); status != http.StatusUnauthorized {
36 t.Fatalf("no token = %d %v, want 401", status, out)
37 }
38
39 // Nothing set yet.
40 status, out := getJSON(t, u, token)
41 if status != http.StatusOK {
42 t.Fatalf("get unset = %d %v, want 200", status, out)
43 }
44 if tgt := str(t, out, "target"); tgt != "" {
45 t.Fatalf("unset target = %q, want empty", tgt)
46 }
47
48 // Set it.
49 status, out = putJSON(t, u, map[string]string{"target": "0.5.0"}, token)
50 if status != http.StatusOK {
51 t.Fatalf("put 0.5.0 = %d %v, want 200", status, out)
52 }
53 if tgt := str(t, out, "target"); tgt != "0.5.0" {
54 t.Fatalf("put response target = %q, want 0.5.0", tgt)
55 }
56
57 // Reads back, with set_by naming the token's own address.
58 status, out = getJSON(t, u, token)
59 if status != http.StatusOK {
60 t.Fatalf("get after set = %d %v, want 200", status, out)
61 }
62 if tgt := str(t, out, "target"); tgt != "0.5.0" {
63 t.Fatalf("get target = %q, want 0.5.0", tgt)
64 }
65 if by := str(t, out, "set_by"); by != "paul@keymail.dev" {
66 t.Fatalf("set_by = %q, want paul@keymail.dev", by)
67 }
68 if at := str(t, out, "set_at"); at == "" {
69 t.Fatal("set_at is empty")
70 }
71
72 // A non-semver target is refused 400.
73 status, out = putJSON(t, u, map[string]string{"target": "nope"}, token)
74 if status != http.StatusBadRequest {
75 t.Fatalf("put non-semver = %d %v, want 400", status, out)
76 }
77
78 // Cut a tag at 0.5.0, then a re-attempt to (re-)set the target to 0.5.0
79 // is refused 409 — SetTarget's own "already released" message.
80 ctx := context.Background()
81 if err := release.Ship(ctx, fx.rel, release.OpsAccount, release.Manifest{
82 Schema: 1, App: "km", Version: "0.5.0-1", Kind: "binary", BuiltAt: "2026-08-08T10:00:00Z",
83 Artifacts: map[string]release.Artifact{"linux-amd64": {File: "km"}},
84 }, map[string][]byte{"linux-amd64": []byte("bin")}); err != nil {
85 t.Fatal(err)
86 }
87 if _, _, err := release.EnsureTag(ctx, fx.rel, release.OpsAccount, "km", "0.5.0-1", "paul@keymail.dev", now); err != nil {
88 t.Fatal(err)
89 }
90 status, out = putJSON(t, u, map[string]string{"target": "0.5.0"}, token)
91 if status != http.StatusConflict {
92 t.Fatalf("put not-above-tags = %d %v, want 409", status, out)
93 }
94 if msg := str(t, out, "error"); !strings.Contains(msg, "already released as tag") {
95 t.Fatalf("409 body = %q, want it to contain %q", msg, "already released as tag")
96 }
97
98 // Non-member: byte-identical 404 to an unknown app.
99 eve := cliToken(t, fx, "eve@keymail.dev", "code-version-target-eve")
100 statusMember, bodyMember := getRaw(t, u, eve)
101 statusUnknown, bodyUnknown := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/no-such-app/version-target", token)
102 if statusMember != http.StatusNotFound || statusUnknown != http.StatusNotFound {
103 t.Fatalf("non-member/unknown status = %d/%d, want both 404", statusMember, statusUnknown)
104 }
105 if bodyMember != bodyUnknown {
106 t.Fatalf("non-member body %q != unknown-app body %q — the 404 is not byte-identical", bodyMember, bodyUnknown)
107 }
108}
109
110// brokenTargetStore fails every write to the version-target object — a
111// bucket outage, not a bad request. Everything else passes through
112// unchanged, so a GET (which reads a different key) is unaffected.
113type brokenTargetStore struct{ release.Store }
114
115const brokenTargetStoreErr = "AccessDenied: s3://carlos-prod-secret-bucket/version-target request id ABC123"
116
117func (b brokenTargetStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
118 if strings.HasSuffix(key, "version-target.json") {
119 return "", errors.New(brokenTargetStoreErr)
120 }
121 return b.Store.Put(ctx, key, body, ifMatch)
122}
123
124// TestAPIVersionTargetSetStoreFailure: a write that fails because the STORE
125// is broken — not because the caller sent anything wrong — must not be
126// reported as a 409 (that status says "your input conflicts with reality",
127// which is false here), and must not echo the store's own error text (bucket
128// names, request ids) to the caller.
129func TestAPIVersionTargetSetStoreFailure(t *testing.T) {
130 fx := newConsoleFixture(t)
131 now := time.Now()
132 if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil {
133 t.Fatal(err)
134 }
135 token := cliToken(t, fx, "paul@keymail.dev", "code-version-target-storefail")
136 fx.srv.cfg.Releases = brokenTargetStore{fx.rel}
137
138 status, out := putJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/version-target",
139 map[string]string{"target": "0.5.0"}, token)
140 if status != http.StatusBadGateway {
141 t.Fatalf("put on a broken store = %d %v, want 502", status, out)
142 }
143 if msg := str(t, out, "error"); strings.Contains(msg, "AccessDenied") || strings.Contains(msg, "carlos-prod-secret-bucket") {
144 t.Fatalf("502 body leaks the store's own error text: %q", msg)
145 }
146}
147
148// TestAPIVersionTargetSetNonMemberByteIdentical404: the PUT door is the one
149// handler in this file with a second gate after apiAccountApp — the write
150// budget (handleAPIVersionTargetSet's s.cli.write.allow) — and that gate must
151// never run, let alone answer, before membership is settled: a non-member's
152// PUT has to meet the exact byte-identical 404 an unknown app's PUT gets,
153// the same property TestAPIVersionTargetGetSet already pins for the GET door.
154//
155// Mutation-mindedness: move the write-budget check ahead of apiAccountApp (or
156// give it its own error body) and this is the test that notices — a
157// non-member would get a 429 or a differently-worded 404 instead of the
158// account-existence-blind refusal every other apicli.go door gives.
159func TestAPIVersionTargetSetNonMemberByteIdentical404(t *testing.T) {
160 fx := newConsoleFixture(t)
161 now := time.Now()
162 if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil {
163 t.Fatal(err)
164 }
165 eve := cliToken(t, fx, "eve@keymail.dev", "code-version-target-put-eve")
166 token := cliToken(t, fx, "paul@keymail.dev", "code-version-target-put-member")
167
168 body := map[string]string{"target": "0.5.0"}
169 statusMember, bodyMember := putRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/version-target", body, eve)
170 statusUnknown, bodyUnknown := putRaw(t, fx.ts.URL+"/api/cli/apps/bab/no-such-app/version-target", body, token)
171 if statusMember != http.StatusNotFound || statusUnknown != http.StatusNotFound {
172 t.Fatalf("non-member/unknown status = %d/%d, want both 404", statusMember, statusUnknown)
173 }
174 if bodyMember != bodyUnknown {
175 t.Fatalf("non-member body %q != unknown-app body %q — the 404 is not byte-identical", bodyMember, bodyUnknown)
176 }
177}
178
179// TestAPITagsListAndGet: the tags list omits the changelog, semver-descending
180// (0.10.0 above 0.6.0, which string order gets wrong); the single-tag door
181// carries the full changelog; a version never tagged is a 404.
182func TestAPITagsListAndGet(t *testing.T) {
183 fx := newConsoleFixture(t)
184 now := time.Now()
185 if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil {
186 t.Fatal(err)
187 }
188 token := cliToken(t, fx, "paul@keymail.dev", "code-tags-list")
189 ctx := context.Background()
190
191 ship := func(version, label string) {
192 t.Helper()
193 if err := release.Ship(ctx, fx.rel, release.OpsAccount, release.Manifest{
194 Schema: 1, App: "km", Version: version, Kind: "binary", BuiltAt: "2026-08-08T10:00:00Z",
195 Label: label,
196 Artifacts: map[string]release.Artifact{"linux-amd64": {File: "km"}},
197 }, map[string][]byte{"linux-amd64": []byte("bin " + version)}); err != nil {
198 t.Fatal(err)
199 }
200 }
201 ship("0.6.0-1", "six")
202 if _, _, err := release.EnsureTag(ctx, fx.rel, release.OpsAccount, "km", "0.6.0-1", "paul@keymail.dev", now); err != nil {
203 t.Fatal(err)
204 }
205 ship("0.10.0-1", "ten")
206 if _, _, err := release.EnsureTag(ctx, fx.rel, release.OpsAccount, "km", "0.10.0-1", "paul@keymail.dev", now); err != nil {
207 t.Fatal(err)
208 }
209
210 status, listOut := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/tags", token)
211 if status != http.StatusOK {
212 t.Fatalf("list tags = %d, want 200", status)
213 }
214 tags, _ := listOut["tags"].([]any)
215 if len(tags) != 2 {
216 t.Fatalf("list tags = %d entries, want 2: %v", len(tags), listOut)
217 }
218 first, _ := tags[0].(map[string]any)
219 second, _ := tags[1].(map[string]any)
220 if first["version"] != "0.10.0" || second["version"] != "0.6.0" {
221 t.Fatalf("order = %v then %v, want 0.10.0 then 0.6.0 (semver-desc, not string order)", first["version"], second["version"])
222 }
223 if _, has := first["changelog"]; has {
224 t.Fatalf("list row carries a changelog key: %v", first)
225 }
226
227 status, oneOut := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/tags/0.6.0", token)
228 if status != http.StatusOK {
229 t.Fatalf("get tag = %d %v, want 200", status, oneOut)
230 }
231 if cl := str(t, oneOut, "changelog"); cl == "" {
232 t.Fatalf("get tag changelog is empty: %v", oneOut)
233 }
234
235 status, missOut := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/tags/9.9.9", token)
236 if status != http.StatusNotFound {
237 t.Fatalf("get missing tag = %d %v, want 404", status, missOut)
238 }
239
240 // A version that isn't a bare semver at all — including a path-traversal
241 // attempt — is refused before the key builder ever runs: ListTags only
242 // ever produces semver-parsable names, so neither string could ever name
243 // a real tag, on any backend.
244 status, traversalOut := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/tags/..%2Fversion-target", token)
245 if status != http.StatusNotFound {
246 t.Fatalf("get tags/..%%2Fversion-target = %d %v, want 404", status, traversalOut)
247 }
248 status, nonSemverOut := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/tags/not-semver", token)
249 if status != http.StatusNotFound {
250 t.Fatalf("get tags/not-semver = %d %v, want 404", status, nonSemverOut)
251 }
252}
253
254// TestAPIReleasesList: Versions() joined against ListTags — the release a
255// tag was cut from carries that tag's name, and every row carries its
256// manifest's label.
257func TestAPIReleasesList(t *testing.T) {
258 fx := newConsoleFixture(t)
259 now := time.Now()
260 if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil {
261 t.Fatal(err)
262 }
263 token := cliToken(t, fx, "paul@keymail.dev", "code-releases-list")
264 ctx := context.Background()
265
266 if err := release.Ship(ctx, fx.rel, release.OpsAccount, release.Manifest{
267 Schema: 1, App: "km", Version: "0.5.0-1", Kind: "binary", BuiltAt: "2026-08-08T10:00:00Z",
268 Label: "fixing modals",
269 Artifacts: map[string]release.Artifact{"linux-amd64": {File: "km"}},
270 }, map[string][]byte{"linux-amd64": []byte("bin")}); err != nil {
271 t.Fatal(err)
272 }
273 if _, _, err := release.EnsureTag(ctx, fx.rel, release.OpsAccount, "km", "0.5.0-1", "paul@keymail.dev", now); err != nil {
274 t.Fatal(err)
275 }
276
277 status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/releases", token)
278 if status != http.StatusOK {
279 t.Fatalf("list releases = %d, want 200", status)
280 }
281 rows, _ := out["releases"].([]any)
282 if len(rows) != 1 {
283 t.Fatalf("releases = %d rows, want 1: %v", len(rows), out)
284 }
285 row, _ := rows[0].(map[string]any)
286 if row["version"] != "0.5.0-1" {
287 t.Fatalf("row version = %v, want 0.5.0-1", row["version"])
288 }
289 if row["label"] != "fixing modals" {
290 t.Fatalf("row label = %v, want %q", row["label"], "fixing modals")
291 }
292 if row["tag"] != "0.5.0" {
293 t.Fatalf("row tag = %v, want 0.5.0", row["tag"])
294 }
295}
296
297// brokenTagsListStore fails every List against a tags/ prefix — a bucket
298// outage on the tag read specifically, not on the releases read ListTags's
299// caller already succeeded at.
300type brokenTagsListStore struct{ release.Store }
301
302const brokenTagsListStoreErr = "AccessDenied: s3://carlos-prod-secret-bucket/tags request id DEF456"
303
304func (b brokenTagsListStore) List(ctx context.Context, prefix string) ([]string, error) {
305 if strings.HasSuffix(prefix, "/tags/") {
306 return nil, errors.New(brokenTagsListStoreErr)
307 }
308 return b.Store.List(ctx, prefix)
309}
310
311// TestAPIReleasesListTagsStoreFailure: view.go's tagByRelease (the fetch
312// form, used here rather than the pure tagIndex fold since this handler has
313// no tag slice of its own) propagates a ListTags failure as an error rather
314// than an empty map — this pins that handleAPIReleasesList turns that error
315// into the same 502 every other broken-store read in this file gets,
316// rather than a 200 whose every row silently claims no tag exists.
317func TestAPIReleasesListTagsStoreFailure(t *testing.T) {
318 fx := newConsoleFixture(t)
319 now := time.Now()
320 if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil {
321 t.Fatal(err)
322 }
323 token := cliToken(t, fx, "paul@keymail.dev", "code-releases-list-tagsfail")
324 if err := release.Ship(context.Background(), fx.rel, release.OpsAccount, release.Manifest{
325 Schema: 1, App: "km", Version: "0.5.0-1", Kind: "binary", BuiltAt: "2026-08-08T10:00:00Z",
326 Artifacts: map[string]release.Artifact{"linux-amd64": {File: "km"}},
327 }, map[string][]byte{"linux-amd64": []byte("bin")}); err != nil {
328 t.Fatal(err)
329 }
330 fx.srv.cfg.Releases = brokenTagsListStore{fx.rel}
331
332 status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/releases", token)
333 if status != http.StatusBadGateway {
334 t.Fatalf("releases list with a broken tags read = %d %v, want 502", status, out)
335 }
336 if msg := str(t, out, "error"); strings.Contains(msg, "AccessDenied") || strings.Contains(msg, "carlos-prod-secret-bucket") {
337 t.Fatalf("502 body leaks the store's own error text: %q", msg)
338 }
339}
340