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 CLI's read-only API surface (apicli.go): `carlos apps` and `carlos
4// channels` reading through the console instead of the bucket directly. The
5// properties that matter, in the order they'd hurt if lost: a token only
6// ever sees ITS OWN memberships, account resolution never guesses between
7// candidates, an unknown account and one you don't belong to are the
8// identical 404, and a channel-ladder read mirrors the CLI's own per-row
9// ERROR behaviour rather than failing the whole response.
10
11import (
12 "bytes"
13 "context"
14 "crypto/rand"
15 "crypto/sha256"
16 "encoding/hex"
17 "encoding/json"
18 "errors"
19 "fmt"
20 "io"
21 "mime/multipart"
22 "net/http"
23 "net/url"
24 "os"
25 "os/exec"
26 "path/filepath"
27 "reflect"
28 "strings"
29 "sync"
30 "testing"
31 "time"
32
33 "github.com/carlosframework/platform/internal/caps"
34 "github.com/carlosframework/platform/internal/domains"
35 "github.com/carlosframework/platform/internal/release"
36)
37
38// getRaw is getJSON's plain-text cousin: some answers here (http.NotFound's
39// body) are not JSON, and asserting on them byte-for-byte is the point of
40// TestAPIChannelsRefusalMatrix.
41func getRaw(t *testing.T, u, bearer string) (int, string) {
42 t.Helper()
43 req, err := http.NewRequest(http.MethodGet, u, nil)
44 if err != nil {
45 t.Fatal(err)
46 }
47 if bearer != "" {
48 req.Header.Set("Authorization", "Bearer "+bearer)
49 }
50 res, err := http.DefaultClient.Do(req)
51 if err != nil {
52 t.Fatal(err)
53 }
54 defer res.Body.Close()
55 body := make([]byte, 0, 256)
56 buf := make([]byte, 256)
57 for {
58 n, err := res.Body.Read(buf)
59 body = append(body, buf[:n]...)
60 if err != nil {
61 break
62 }
63 }
64 return res.StatusCode, string(body)
65}
66
67// postRaw is postJSON's byte-exact cousin: TestAPIPromoteStableRefused needs
68// the raw response body, not a decoded map, to assert two responses are
69// IDENTICAL rather than merely equal key-by-key.
70func postRaw(t *testing.T, u string, body any, bearer string) (int, string) {
71 t.Helper()
72 raw, err := json.Marshal(body)
73 if err != nil {
74 t.Fatal(err)
75 }
76 req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(raw))
77 if err != nil {
78 t.Fatal(err)
79 }
80 req.Header.Set("Content-Type", "application/json")
81 if bearer != "" {
82 req.Header.Set("Authorization", "Bearer "+bearer)
83 }
84 res, err := http.DefaultClient.Do(req)
85 if err != nil {
86 t.Fatal(err)
87 }
88 defer res.Body.Close()
89 out, err := io.ReadAll(res.Body)
90 if err != nil {
91 t.Fatal(err)
92 }
93 return res.StatusCode, string(out)
94}
95
96// TestAPIAppsListsMemberships: a token only ever learns about the accounts
97// IT belongs to, and only that account's apps — a second account's OWN app
98// (secretapp, claimed in bac) never leaks into bab's list.
99func TestAPIAppsListsMemberships(t *testing.T) {
100 fx := newConsoleFixture(t)
101 now := time.Now()
102 // bac: a second account alice is NOT a member of, with its own app.
103 if err := fx.st.CreateAccount(2, "bac", "widgets", "bob@keymail.dev", now); err != nil {
104 t.Fatal(err)
105 }
106 if _, err := fx.st.CreateAppIn(2, "secretapp", "bob@keymail.dev", now); err != nil {
107 t.Fatal(err)
108 }
109 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
110 t.Fatal(err)
111 }
112 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
113 t.Fatal(err)
114 }
115 token := cliToken(t, fx, "alice@keymail.dev", "code-apps-alice")
116
117 status, out := getJSON(t, fx.ts.URL+"/api/cli/apps", token)
118 if status != http.StatusOK {
119 t.Fatalf("apps = %d %v", status, out)
120 }
121 accts, _ := out["accounts"].([]any)
122 if len(accts) != 1 {
123 t.Fatalf("accounts = %v, want exactly bab (bac must not appear)", out["accounts"])
124 }
125 a, _ := accts[0].(map[string]any)
126 if str(t, a, "sqid") != "bab" || str(t, a, "name") != "ops" || str(t, a, "role") != "member" {
127 t.Fatalf("account = %v", a)
128 }
129 apps, _ := a["apps"].([]any)
130 found := false
131 for _, v := range apps {
132 if v == "titogo" {
133 found = true
134 }
135 if v == "secretapp" {
136 t.Fatalf("bab's apps leaked bac's app: %v", apps)
137 }
138 }
139 if !found {
140 t.Fatalf("apps = %v, want titogo among them", apps)
141 }
142}
143
144// TestAPIAppsNoToken: the same 401 cliUser always gives.
145func TestAPIAppsNoToken(t *testing.T) {
146 fx := newConsoleFixture(t)
147 status, out := getJSON(t, fx.ts.URL+"/api/cli/apps", "")
148 if status != http.StatusUnauthorized {
149 t.Fatalf("apps with no token = %d %v, want 401", status, out)
150 }
151 if !strings.Contains(str(t, out, "error"), "not signed in") {
152 t.Fatalf("apps 401 body = %v, want the cliUser sentence", out)
153 }
154}
155
156// TestAPIChannelsHappy: a promoted channel reads back through the API
157// exactly as the CLI's own `carlos channels` reads it — the account segment
158// works identically whether given as the sqid or the name, `rung` is always
159// present (empty for a channel outside the ladder's vocabulary), and
160// `promoted_at` is a real RFC3339 timestamp a typed client can parse.
161func TestAPIChannelsHappy(t *testing.T) {
162 fx := newConsoleFixture(t)
163 now := time.Now()
164 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
165 t.Fatal(err)
166 }
167 shipVersion(t, fx, "titogo", "abc1234")
168 seedChannel(t, fx, "titogo", "canary/x", "abc1234")
169 // A second, non-rung channel — "adhoc" matches no ladder rung, so `rung`
170 // must come back "" rather than being omitted.
171 shipVersion(t, fx, "titogo", "def5678")
172 seedChannel(t, fx, "titogo", "adhoc", "def5678")
173 token := cliToken(t, fx, "paul@keymail.dev", "code-channels-paul")
174
175 for _, seg := range []string{"bab", "ops"} {
176 status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/"+seg+"/titogo/channels", token)
177 if status != http.StatusOK {
178 t.Fatalf("channels (%s) = %d %v", seg, status, out)
179 }
180 chans, _ := out["channels"].([]any)
181 if len(chans) != 2 {
182 t.Fatalf("channels (%s) = %v, want exactly two entries", seg, out["channels"])
183 }
184 byName := map[string]map[string]any{}
185 for _, c := range chans {
186 m, _ := c.(map[string]any)
187 byName[str(t, m, "channel")] = m
188 }
189 canary, ok := byName["canary/x"]
190 if !ok {
191 t.Fatalf("channels (%s) missing canary/x: %v", seg, chans)
192 }
193 if str(t, canary, "version") != "abc1234" || str(t, canary, "rung") != "canary" {
194 t.Fatalf("canary/x entry (%s) = %v", seg, canary)
195 }
196 if _, err := time.Parse(time.RFC3339, str(t, canary, "promoted_at")); err != nil {
197 t.Fatalf("canary/x promoted_at (%s) = %q, not RFC3339: %v", seg, str(t, canary, "promoted_at"), err)
198 }
199 adhoc, ok := byName["adhoc"]
200 if !ok {
201 t.Fatalf("channels (%s) missing adhoc: %v", seg, chans)
202 }
203 if str(t, adhoc, "version") != "def5678" {
204 t.Fatalf("adhoc entry (%s) = %v", seg, adhoc)
205 }
206 if v, present := adhoc["rung"]; !present || v != "" {
207 t.Fatalf("adhoc rung (%s) = %v (present=%v), want present and empty", seg, v, present)
208 }
209 if _, err := time.Parse(time.RFC3339, str(t, adhoc, "promoted_at")); err != nil {
210 t.Fatalf("adhoc promoted_at (%s) = %q, not RFC3339: %v", seg, str(t, adhoc, "promoted_at"), err)
211 }
212 }
213}
214
215// TestAPIChannelsErrorRow: a channel pointer that fails verification becomes
216// one erroring ROW, not a failed response — the good channel alongside it
217// must still read back intact.
218func TestAPIChannelsErrorRow(t *testing.T) {
219 fx := newConsoleFixture(t)
220 now := time.Now()
221 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
222 t.Fatal(err)
223 }
224 shipVersion(t, fx, "titogo", "abc1234")
225 seedChannel(t, fx, "titogo", "canary/x", "abc1234")
226 // A garbage pointer at a second channel key — not signed, not even JSON —
227 // planted directly in the bucket the way a corrupted or hand-edited
228 // object would arrive.
229 garbage := []byte("not a valid signed channel pointer")
230 if _, err := fx.rel.Put(context.Background(), release.ChannelKey(release.OpsAccount, "titogo", "broken"), garbage, ""); err != nil {
231 t.Fatal(err)
232 }
233 token := cliToken(t, fx, "paul@keymail.dev", "code-channels-error-row")
234
235 status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", token)
236 if status != http.StatusOK {
237 t.Fatalf("channels = %d %v, want 200 even with one bad pointer", status, out)
238 }
239 chans, _ := out["channels"].([]any)
240 if len(chans) != 2 {
241 t.Fatalf("channels = %v, want exactly two rows (one good, one broken)", out["channels"])
242 }
243 byName := map[string]map[string]any{}
244 for _, c := range chans {
245 m, _ := c.(map[string]any)
246 byName[str(t, m, "channel")] = m
247 }
248 broken, ok := byName["broken"]
249 if !ok {
250 t.Fatalf("channels missing the broken row: %v", chans)
251 }
252 if str(t, broken, "error") == "" {
253 t.Fatalf("broken row = %v, want a non-empty error", broken)
254 }
255 if _, present := broken["version"]; present {
256 t.Fatalf("broken row = %v, want no version alongside the error", broken)
257 }
258 good, ok := byName["canary/x"]
259 if !ok {
260 t.Fatalf("channels missing the good row: %v", chans)
261 }
262 if str(t, good, "version") != "abc1234" || str(t, good, "rung") != "canary" {
263 t.Fatalf("good row = %v, want it unaffected by the broken one", good)
264 }
265}
266
267// TestAPIChannelsPipelineEnvelope pins the additive "pipeline_entry"/
268// "pipeline_channels" envelope keys (release pipelines v2, Task 11, review
269// fix round 1): a pipelined app's channels response carries the REDUCED view
270// as two flat top-level keys — "pipeline_entry" and "pipeline_channels":
271// [names in order] — sharing release.GetPipeline's read with
272// handleAPIPipelineGet (Task 6) so the two doors can never disagree. They
273// are deliberately NOT nested under a "pipeline" key: that JSON path already
274// names the full release.Pipeline object on the `/pipeline` door, and
275// reusing it here for a differently-shaped value would mean the same key
276// meaning two incompatible things depending on which door answered.
277//
278// A legacy app (no pipeline.json) gets BOTH keys OMITTED, never present as
279// null — this door does not attempt to distinguish "no pipeline" from "the
280// console could not read one" (see TestAPIChannelsPipelineUnreadableOmitsKey);
281// that distinction belongs to the dedicated `/pipeline` door alone. Old CLIs
282// decode "channels" and ignore the rest either way, so both shapes are
283// additive.
284func TestAPIChannelsPipelineEnvelope(t *testing.T) {
285 fx := newConsoleFixture(t)
286 token := cliToken(t, fx, "paul@keymail.dev", "code-channels-pipeline")
287
288 // Pipelined app: created through the API door, which seeds the
289 // one-channel edge default (TestAPIPipelineRoundTripAndDefaults), then
290 // widened to two channels so entry != the app's only channel.
291 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "piped"}, token)
292 if status != http.StatusOK {
293 t.Fatalf("create app = %d %v", status, out)
294 }
295 status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/piped/pipeline",
296 map[string]any{"channels": []map[string]any{{"name": "edge"}, {"name": "production"}}}, token)
297 if status != http.StatusOK {
298 t.Fatalf("pipeline put = %d %v", status, out)
299 }
300
301 status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/piped/channels", token)
302 if status != http.StatusOK {
303 t.Fatalf("channels (pipelined) = %d %v", status, out)
304 }
305 if str(t, out, "pipeline_entry") != "edge" {
306 t.Fatalf("pipeline_entry = %v, want edge", out["pipeline_entry"])
307 }
308 names, _ := out["pipeline_channels"].([]any)
309 if len(names) != 2 || names[0] != "edge" || names[1] != "production" {
310 t.Fatalf("pipeline_channels = %v, want [edge production]", names)
311 }
312 // The channel-rows key is untouched: a fresh app with no promotions has
313 // none yet.
314 chans, _ := out["channels"].([]any)
315 if len(chans) != 0 {
316 t.Fatalf("channels (pipelined, unpromoted) = %v, want none yet", chans)
317 }
318
319 // Legacy app: created directly, bypassing the API create door's
320 // default-pipeline write — no pipeline.json exists at all, so BOTH keys
321 // must be absent, together, exactly as the unreadable case below.
322 if _, err := fx.st.CreateAppIn(OpsAccountID, "legacyapp", "paul@keymail.dev", time.Now()); err != nil {
323 t.Fatal(err)
324 }
325 status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/legacyapp/channels", token)
326 if status != http.StatusOK {
327 t.Fatalf("channels (legacy) = %d %v", status, out)
328 }
329 if _, present := out["pipeline_entry"]; present {
330 t.Fatalf("channels (legacy) pipeline_entry = %v, want the key OMITTED", out["pipeline_entry"])
331 }
332 if _, present := out["pipeline_channels"]; present {
333 t.Fatalf("channels (legacy) pipeline_channels = %v, want the key OMITTED", out["pipeline_channels"])
334 }
335}
336
337// TestAPIChannelsPipelineUnreadableOmitsKey: an app whose pipeline.json
338// exists but fails verification gets the SAME wire shape as a legacy app —
339// both pipeline_entry/pipeline_channels OMITTED. Unlike the dedicated
340// `/pipeline` door (which answers a genuinely-missing pipeline with 200/null
341// and an unreadable one with 502, Task 6), this door does not attempt that
342// distinction at all (review fix round 1) — it is a convenience summary for
343// deploy resolution, and an absent entry channel is the correct conservative
344// answer whether the pipeline is missing or merely unreadable. The channels
345// listing itself is unharmed: it is read-only info this door must not fail
346// over a pipeline it doesn't strictly need.
347func TestAPIChannelsPipelineUnreadableOmitsKey(t *testing.T) {
348 fx := newConsoleFixture(t)
349 if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil {
350 t.Fatal(err)
351 }
352 if _, err := fx.rel.Put(context.Background(), release.PipelineKey("bab", "widget"), []byte("not json"), ""); err != nil {
353 t.Fatal(err)
354 }
355 token := cliToken(t, fx, "paul@keymail.dev", "code-channels-pipeline-unreadable")
356
357 status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/channels", token)
358 if status != http.StatusOK {
359 t.Fatalf("channels (unreadable pipeline) = %d %v, want 200 — the listing itself is fine", status, out)
360 }
361 if _, present := out["pipeline_entry"]; present {
362 t.Fatalf("channels (unreadable pipeline) pipeline_entry = %v, want the key OMITTED, not present (even as null)", out)
363 }
364 if _, present := out["pipeline_channels"]; present {
365 t.Fatalf("channels (unreadable pipeline) pipeline_channels = %v, want the key OMITTED, not present (even as null)", out)
366 }
367}
368
369// TestAPIChannelsRefusalMatrix: an unknown account, and an account you belong
370// to but an app it hasn't claimed, are the SAME 404 — byte-identical to
371// http.NotFound's own output, so a token holder learns nothing by probing.
372func TestAPIChannelsRefusalMatrix(t *testing.T) {
373 fx := newConsoleFixture(t)
374 now := time.Now()
375
376 // (a) member of nothing.
377 eve := cliToken(t, fx, "eve@keymail.dev", "code-channels-eve")
378 statusA, bodyA := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", eve)
379 if statusA != http.StatusNotFound {
380 t.Fatalf("member-of-nothing channels = %d, want 404", statusA)
381 }
382
383 // (b) member of bab, but the app is unclaimed there.
384 if err := fx.st.AddMember(OpsAccountID, "frank@keymail.dev", RoleMember, now); err != nil {
385 t.Fatal(err)
386 }
387 frank := cliToken(t, fx, "frank@keymail.dev", "code-channels-frank")
388 statusB, bodyB := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/ghost-app/channels", frank)
389 if statusB != http.StatusNotFound {
390 t.Fatalf("unclaimed-app channels = %d, want 404", statusB)
391 }
392
393 if bodyA != bodyB {
394 t.Fatalf("the two refusals are not byte-identical:\n(a) %q\n(b) %q", bodyA, bodyB)
395 }
396 // http.NotFound's own, well-known output — pinned so a future change to
397 // either handler is caught even if it stays self-consistent.
398 if bodyA != "404 page not found\n" {
399 t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", bodyA)
400 }
401
402 // (c) the same probe carrying ?exact=sqid — the -account-id transport is
403 // not a second, chattier door: a non-member meets the identical refusal
404 // whether they name the account exactly or loosely.
405 statusC, bodyC := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels?exact=sqid", eve)
406 if statusC != http.StatusNotFound || bodyC != bodyA {
407 t.Fatalf("exact=sqid member-of-nothing = %d %q, want the identical 404 %q", statusC, bodyC, bodyA)
408 }
409}
410
411// TestAPIAccountSqidNameCollisionRefused: resolution is SYMMETRIC — a
412// segment matching one account's SQID and another's NAME is ambiguous, not a
413// sqid win. Precedence is how a reference silently changes meaning the day a
414// colliding account appears; here the API refuses and names the flag that
415// settles it, and -account-id (?exact=sqid) then answers unambiguously.
416func TestAPIAccountSqidNameCollisionRefused(t *testing.T) {
417 fx := newConsoleFixture(t)
418 now := time.Now()
419 // bac's NAME is literally "bab" — the sqid of the ops account. Alice
420 // belongs to both.
421 if err := fx.st.CreateAccount(2, "bac", "bab", "alice@keymail.dev", now); err != nil {
422 t.Fatal(err)
423 }
424 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
425 t.Fatal(err)
426 }
427 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
428 t.Fatal(err)
429 }
430 token := cliToken(t, fx, "alice@keymail.dev", "code-sqid-name-collision")
431
432 status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", token)
433 if status != http.StatusConflict {
434 t.Fatalf("collision channels = %d %v, want 409 (bab is both a sqid and a name here)", status, out)
435 }
436 msg := str(t, out, "error")
437 if !strings.Contains(msg, "bab") || !strings.Contains(msg, "bac") || !strings.Contains(msg, "-account-id") {
438 t.Fatalf("collision error = %q, want both candidates and --account-id named", msg)
439 }
440
441 // -account-id settles it: as a sqid, "bab" is the ops account alone.
442 status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels?exact=sqid", token)
443 if status != http.StatusOK {
444 t.Fatalf("exact=sqid channels = %d %v, want 200 (the ops account, which holds titogo)", status, out)
445 }
446}
447
448// TestAPIAccountAmbiguousNameRefused: two of the caller's own accounts
449// sharing a name is refused with 409 and both candidates named — never
450// silently resolved to one of them — and the refusal names -account-id,
451// which is the only thing the caller can do about it.
452func TestAPIAccountAmbiguousNameRefused(t *testing.T) {
453 fx := newConsoleFixture(t)
454 now := time.Now()
455 sqidA, sqidB := domains.Sqid(2), domains.Sqid(3)
456 if err := fx.st.CreateAccount(2, sqidA, "widgets", "alice@keymail.dev", now); err != nil {
457 t.Fatal(err)
458 }
459 if err := fx.st.CreateAccount(3, sqidB, "widgets", "alice@keymail.dev", now); err != nil {
460 t.Fatal(err)
461 }
462 token := cliToken(t, fx, "alice@keymail.dev", "code-ambiguous-name")
463
464 status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/widgets/anyapp/channels", token)
465 if status != http.StatusConflict {
466 t.Fatalf("ambiguous-name channels = %d %v, want 409", status, out)
467 }
468 msg := str(t, out, "error")
469 if !strings.Contains(msg, sqidA) || !strings.Contains(msg, sqidB) {
470 t.Fatalf("ambiguous-name error = %q, want both %q and %q named", msg, sqidA, sqidB)
471 }
472 if !strings.Contains(msg, "-account-id") {
473 t.Fatalf("ambiguous-name error = %q, want it to name the --account-id flag", msg)
474 }
475}
476
477// TestAPIAccountExactSqid: the ?exact=sqid contract the CLI's -account-id
478// flag rides on. It matches the segment as a SQID only — a name, however
479// unique to this caller, never answers — so the reference a person reaches
480// for when the pretty one is ambiguous can never itself become ambiguous.
481func TestAPIAccountExactSqid(t *testing.T) {
482 fx := newConsoleFixture(t)
483 now := time.Now()
484 sqidA, sqidB := domains.Sqid(2), domains.Sqid(3)
485 // Two of alice's own accounts named "amadan"; only the first holds web.
486 if err := fx.st.CreateAccount(2, sqidA, "amadan", "alice@keymail.dev", now); err != nil {
487 t.Fatal(err)
488 }
489 if err := fx.st.CreateAccount(3, sqidB, "amadan", "alice@keymail.dev", now); err != nil {
490 t.Fatal(err)
491 }
492 if _, err := fx.st.CreateAppIn(2, "web", "alice@keymail.dev", now); err != nil {
493 t.Fatal(err)
494 }
495 token := cliToken(t, fx, "alice@keymail.dev", "code-exact-sqid")
496
497 // The bare name is ambiguous — and says so, in the words a caller can act on.
498 status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/amadan/web/channels", token)
499 if status != http.StatusConflict {
500 t.Fatalf("ambiguous amadan = %d %v, want 409", status, out)
501 }
502 msg := str(t, out, "error")
503 if !strings.Contains(msg, sqidA) || !strings.Contains(msg, sqidB) || !strings.Contains(msg, "-account-id") {
504 t.Fatalf("ambiguous error = %q, want both sqids and --account-id named", msg)
505 }
506
507 // The sqid, exactly, resolves to the account that holds the app.
508 status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/"+sqidA+"/web/channels?exact=sqid", token)
509 if status != http.StatusOK {
510 t.Fatalf("exact=sqid %s = %d %v, want 200", sqidA, status, out)
511 }
512
513 // A NAME under exact=sqid does not match at all — the same 404 an unknown
514 // account gets, byte-identical to http.NotFound's own output.
515 st, body := getRaw(t, fx.ts.URL+"/api/cli/apps/amadan/web/channels?exact=sqid", token)
516 if st != http.StatusNotFound || body != "404 page not found\n" {
517 t.Fatalf("exact=sqid with a name = %d %q, want the stdlib 404", st, body)
518 }
519
520 // The caller's OTHER account, named exactly, simply hasn't claimed web:
521 // the identical 404, so exact mode leaks nothing either.
522 st, body2 := getRaw(t, fx.ts.URL+"/api/cli/apps/"+sqidB+"/web/channels?exact=sqid", token)
523 if st != http.StatusNotFound || body2 != body {
524 t.Fatalf("exact=sqid unclaimed app = %d %q, want the identical 404 %q", st, body2, body)
525 }
526}
527
528// TestAPIAccountNameMatchIsCaseInsensitive: a unique name match applies
529// EqualFold, matching resolveAccount's own doctrine (accounts.go).
530func TestAPIAccountNameMatchIsCaseInsensitive(t *testing.T) {
531 fx := newConsoleFixture(t)
532 now := time.Now()
533 sqid := domains.Sqid(2)
534 if err := fx.st.CreateAccount(2, sqid, "Widgets", "alice@keymail.dev", now); err != nil {
535 t.Fatal(err)
536 }
537 if _, err := fx.st.CreateAppIn(2, "gizmo", "alice@keymail.dev", now); err != nil {
538 t.Fatal(err)
539 }
540 token := cliToken(t, fx, "alice@keymail.dev", "code-case-insensitive-name")
541
542 status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/widgets/gizmo/channels", token)
543 if status != http.StatusOK {
544 t.Fatalf("case-insensitive name match = %d %v, want 200", status, out)
545 }
546}
547
548// ---- promote + rollback (apicli.go) -----------------------------------------
549//
550// The CLI's write doors: `carlos promote` and `carlos rollback` through the
551// same browser doors as the console UI (handlePromote/handleRollback,
552// server.go), minus CSRF (a JSON bearer request has no session cookie to
553// forge) and minus the passkey step-up (there is no browser here to redirect
554// into Keymail) — stable is refused outright instead.
555
556// TestAPIPromoteHappy: a shipped version reaches a fresh channel, the pointer
557// reads back the new version, and the audit trail names it a CLI action.
558func TestAPIPromoteHappy(t *testing.T) {
559 fx := newConsoleFixture(t)
560 now := time.Now()
561 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
562 t.Fatal(err)
563 }
564 shipVersion(t, fx, "titogo", "abc1234")
565 token := cliToken(t, fx, "paul@keymail.dev", "code-promote-happy")
566
567 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
568 map[string]string{"version": "abc1234", "channel": "canary/x"}, token)
569 if status != http.StatusOK {
570 t.Fatalf("promote = %d %v", status, out)
571 }
572 if str(t, out, "result") != "promoted" || str(t, out, "to") != "canary" {
573 t.Fatalf("promote body = %v", out)
574 }
575 if v, present := out["noop"]; !present || v != false {
576 t.Fatalf("promote noop = %v (present=%v), want false", v, present)
577 }
578 // Additive (carlos-deploy §3): the CLI learns from this same answer
579 // whether what it just moved is waiting out a bake window. A canary never
580 // bakes, so this one is not held.
581 if v, present := out["held"]; !present || v != false {
582 t.Fatalf("promote held = %v (present=%v), want false", v, present)
583 }
584 cp := fx.channelVersion(t, "titogo", "canary/x")
585 if cp.Version != "abc1234" {
586 t.Fatalf("channel after promote = %q, want abc1234", cp.Version)
587 }
588 events, err := fx.st.Events(OpsAccountID, "titogo", 10)
589 if err != nil {
590 t.Fatal(err)
591 }
592 found := false
593 for _, e := range events {
594 if e.Action == "promote" && strings.Contains(e.Detail, "(cli)") {
595 found = true
596 }
597 }
598 if !found {
599 t.Fatalf("events = %v, want a promote row whose detail names the CLI", events)
600 }
601}
602
603// TestAPIPromotePipelinedChannelNamesItselfInTo: the promote door's "to"
604// field is PromoteResult.To, which was the target channel's RUNG — and an
605// app-defined channel is not a rung, so a pipelined promotion answered with
606// "to": "". The CLI does not print it (it prints what it asked for), but the
607// field is the API's own record of where the build went, and "" is a worse
608// answer than the channel's name for every other client. Wire-additive: a
609// fuller string where an empty one used to sit.
610func TestAPIPromotePipelinedChannelNamesItselfInTo(t *testing.T) {
611 fx := newConsoleFixture(t)
612 token := cliToken(t, fx, "paul@keymail.dev", "code-promote-pipelined")
613 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new",
614 map[string]string{"name": "piped"}, token); status != http.StatusOK {
615 t.Fatalf("create app = %d %v", status, out)
616 }
617 if status, out := putJSON(t, fx.ts.URL+"/api/cli/apps/bab/piped/pipeline",
618 map[string]any{"channels": []map[string]any{{"name": "edge"}, {"name": "production"}}}, token); status != http.StatusOK {
619 t.Fatalf("pipeline put = %d %v", status, out)
620 }
621 shipVersion(t, fx, "piped", "abc1234")
622 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/piped/promote",
623 map[string]string{"version": "abc1234", "channel": "edge"}, token); status != http.StatusOK {
624 t.Fatalf("promote to edge = %d %v", status, out)
625 }
626 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/piped/promote",
627 map[string]string{"version": "abc1234", "channel": "production"}, token)
628 if status != http.StatusOK {
629 t.Fatalf("promote to production = %d %v", status, out)
630 }
631 if got := str(t, out, "to"); got != "production" {
632 t.Errorf("promote body to = %q, want %q: %v", got, "production", out)
633 }
634 if got := str(t, out, "from"); got != "edge" {
635 t.Errorf("promote body from = %q, want %q: %v", got, "edge", out)
636 }
637}
638
639// TestAPIPromoteStableRefused: stable cannot be reached over the API at all —
640// there is no passkey to step up to — and the refusal is byte-identical for a
641// member token and an owner token, so a token holder learns nothing about
642// their own role from this door (spec §3).
643func TestAPIPromoteStableRefused(t *testing.T) {
644 fx := newConsoleFixture(t)
645 now := time.Now()
646 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
647 t.Fatal(err)
648 }
649 shipVersion(t, fx, "titogo", "abc1234")
650 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
651 t.Fatal(err)
652 }
653 owner := cliToken(t, fx, "paul@keymail.dev", "code-stable-owner")
654 member := cliToken(t, fx, "alice@keymail.dev", "code-stable-member")
655
656 body := map[string]string{"version": "abc1234", "channel": "stable"}
657 statusO, outO := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", body, owner)
658 statusM, outM := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", body, member)
659 if statusO != http.StatusForbidden || statusM != http.StatusForbidden {
660 t.Fatalf("stable promote = owner %d, member %d, want both 403", statusO, statusM)
661 }
662 if outO != outM {
663 t.Fatalf("stable refusal not byte-identical:\nowner: %q\nmember: %q", outO, outM)
664 }
665 wantURL := fx.ts.URL + "/a/bab/apps/titogo"
666 if !strings.Contains(outO, wantURL) {
667 t.Fatalf("stable refusal = %q, want console_url %q", outO, wantURL)
668 }
669 if !strings.Contains(outO, "passkey") {
670 t.Fatalf("stable refusal = %q, want it to name the passkey requirement", outO)
671 }
672 // The refusal must be true, not just spoken: neither call may have moved
673 // the rung gate to a spot where PromoteOnLadder ran first and the 403 was
674 // merely decorative. If a future edit reordered the gate below
675 // PromoteOnLadder, this is what would catch it — the test above would
676 // still pass on status and body alone.
677 if _, err := release.Channel(context.Background(), fx.rel, fx.pub, release.OpsAccount, "titogo", "stable"); err == nil {
678 t.Fatalf("stable channel exists after two refused promotions, want it absent")
679 }
680}
681
682// TestAPIPromoteNoKey: a read-only deployment (no signing key) refuses every
683// promotion with 503, the same as the browser door.
684func TestAPIPromoteNoKey(t *testing.T) {
685 fx := newConsoleFixtureKeyed(t, false)
686 now := time.Now()
687 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
688 t.Fatal(err)
689 }
690 token := cliToken(t, fx, "paul@keymail.dev", "code-promote-nokey")
691
692 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
693 map[string]string{"version": "abc1234", "channel": "canary/x"}, token)
694 if status != http.StatusServiceUnavailable {
695 t.Fatalf("promote with no key = %d %v, want 503", status, out)
696 }
697}
698
699// TestAPIPromoteBadVersion: a version that fails release.ValidVersion is a
700// 400 naming the problem, before anything touches the release store.
701func TestAPIPromoteBadVersion(t *testing.T) {
702 fx := newConsoleFixture(t)
703 now := time.Now()
704 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
705 t.Fatal(err)
706 }
707 token := cliToken(t, fx, "paul@keymail.dev", "code-promote-badversion")
708
709 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
710 map[string]string{"version": "../etc/passwd", "channel": "canary/x"}, token)
711 if status != http.StatusBadRequest {
712 t.Fatalf("bad version promote = %d %v, want 400", status, out)
713 }
714 if !strings.Contains(str(t, out, "error"), "version") {
715 t.Fatalf("bad version error = %q, want it to name the version", str(t, out, "error"))
716 }
717}
718
719// TestAPIPromoteBadChannel: an invalid channel name is a 400 naming the
720// problem.
721func TestAPIPromoteBadChannel(t *testing.T) {
722 fx := newConsoleFixture(t)
723 now := time.Now()
724 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
725 t.Fatal(err)
726 }
727 shipVersion(t, fx, "titogo", "abc1234")
728 token := cliToken(t, fx, "paul@keymail.dev", "code-promote-badchannel")
729
730 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
731 map[string]string{"version": "abc1234", "channel": "bad channel"}, token)
732 if status != http.StatusBadRequest {
733 t.Fatalf("bad channel promote = %d %v, want 400", status, out)
734 }
735 if !strings.Contains(str(t, out, "error"), "channel") {
736 t.Fatalf("bad channel error = %q, want it to name the channel", str(t, out, "error"))
737 }
738}
739
740// TestAPIPromoteUnshippedVersion: a well-formed version that was never
741// shipped is a 404 naming the app, the same sentence the browser door uses.
742func TestAPIPromoteUnshippedVersion(t *testing.T) {
743 fx := newConsoleFixture(t)
744 now := time.Now()
745 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
746 t.Fatal(err)
747 }
748 token := cliToken(t, fx, "paul@keymail.dev", "code-promote-unshipped")
749
750 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
751 map[string]string{"version": "ghost999", "channel": "canary/x"}, token)
752 if status != http.StatusNotFound {
753 t.Fatalf("unshipped version promote = %d %v, want 404", status, out)
754 }
755 if !strings.Contains(str(t, out, "error"), "not a shipped release") {
756 t.Fatalf("unshipped version error = %q, want the browser door's sentence", str(t, out, "error"))
757 }
758}
759
760// TestAPIPromoteHotfixIsOrderOnly: `hotfix` became a real field on this door
761// with release pipelines v2 (§5, and the CLI's own client-side refusal of
762// -hotfix in API mode goes with it). It bypasses ORDER, and that is the whole
763// of what it does — the stable rule a pipeline-less app carries still refuses
764// it, byte for byte.
765//
766// It replaces TestAPIPromoteNoHotfix, which pinned the previous shape (the
767// request struct had no such field, so the key was silently ignored). The
768// change is deliberate: the flag is the sanctioned, recorded bypass of the
769// ladder's ORDER, and it was never the thing standing between a token and
770// stable — the rung gate is.
771func TestAPIPromoteHotfixIsOrderOnly(t *testing.T) {
772 fx := newConsoleFixture(t)
773 now := time.Now()
774 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
775 t.Fatal(err)
776 }
777 shipVersion(t, fx, "titogo", "abc1234")
778 token := cliToken(t, fx, "paul@keymail.dev", "code-promote-hotfix")
779 promoteURL := fx.ts.URL + "/api/cli/apps/bab/titogo/promote"
780
781 // abc1234 is on no rung yet (nothing has promoted it there), so promoting
782 // straight to "beta" is the ladder-ORDERING refusal (beta is entered from
783 // edge only) — not the "not a rung" arm a nested non-canary segment like
784 // "beta/x" would hit instead.
785 status, out := postJSON(t, promoteURL,
786 map[string]any{"version": "abc1234", "channel": "beta"}, token)
787 if status != http.StatusConflict {
788 t.Fatalf("ladder-skip promote = %d %v, want 409", status, out)
789 }
790 if !strings.Contains(str(t, out, "error"), "beta is entered from edge only") {
791 t.Fatalf("ladder-skip error = %q, want the ladder-ordering refusal text", str(t, out, "error"))
792 }
793 if _, err := release.Channel(context.Background(), fx.rel, fx.pub, release.OpsAccount, "titogo", "beta"); err == nil {
794 t.Fatalf("beta channel exists after a refused promotion, want it absent")
795 }
796
797 // The same request with the flag lands, and the pointer records it.
798 status, out = postJSON(t, promoteURL,
799 map[string]any{"version": "abc1234", "channel": "beta", "hotfix": true}, token)
800 if status != http.StatusOK || str(t, out, "result") != "promoted" {
801 t.Fatalf("hotfixed ladder-skip = %d %v, want 200 promoted", status, out)
802 }
803 p := fx.channelVersion(t, "titogo", "beta")
804 if p.Version != "abc1234" {
805 t.Fatalf("beta serves %q, want abc1234", p.Version)
806 }
807 if len(p.Audit) == 0 || !p.Audit[0].Hotfix {
808 t.Fatalf("beta's audit trail = %+v, want the bypass recorded on the pointer", p.Audit)
809 }
810
811 // And it buys nothing at stable, which is a RULE, not an order.
812 status, out = postJSON(t, promoteURL,
813 map[string]any{"version": "abc1234", "channel": "stable", "hotfix": true}, token)
814 if status != http.StatusForbidden {
815 t.Fatalf("hotfixed stable promote = %d %v, want the 403 passkey refusal", status, out)
816 }
817 if _, err := release.Channel(context.Background(), fx.rel, fx.pub, release.OpsAccount, "titogo", "stable"); err == nil {
818 t.Fatalf("stable channel exists after a refused promotion, want it absent")
819 }
820}
821
822// TestAPIRollbackHappy: two promotions to the same channel, then a rollback,
823// return the pointer to the first version.
824func TestAPIRollbackHappy(t *testing.T) {
825 fx := newConsoleFixture(t)
826 now := time.Now()
827 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
828 t.Fatal(err)
829 }
830 shipVersion(t, fx, "titogo", "abc1234")
831 shipVersion(t, fx, "titogo", "def5678")
832 token := cliToken(t, fx, "paul@keymail.dev", "code-rollback-happy")
833
834 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
835 map[string]string{"version": "abc1234", "channel": "canary/x"}, token)
836 if status != http.StatusOK {
837 t.Fatalf("first promote = %d %v", status, out)
838 }
839 status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
840 map[string]string{"version": "def5678", "channel": "canary/x"}, token)
841 if status != http.StatusOK {
842 t.Fatalf("second promote = %d %v", status, out)
843 }
844 if cp := fx.channelVersion(t, "titogo", "canary/x"); cp.Version != "def5678" {
845 t.Fatalf("channel before rollback = %q, want def5678", cp.Version)
846 }
847
848 status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/rollback",
849 map[string]string{"channel": "canary/x"}, token)
850 if status != http.StatusOK {
851 t.Fatalf("rollback = %d %v", status, out)
852 }
853 if str(t, out, "result") != "rolled back" {
854 t.Fatalf("rollback body = %v", out)
855 }
856 if cp := fx.channelVersion(t, "titogo", "canary/x"); cp.Version != "abc1234" {
857 t.Fatalf("channel after rollback = %q, want abc1234 (back to the first version)", cp.Version)
858 }
859 events, err := fx.st.Events(OpsAccountID, "titogo", 10)
860 if err != nil {
861 t.Fatal(err)
862 }
863 found := false
864 for _, e := range events {
865 if e.Action == "rollback" && strings.Contains(e.Detail, "(cli)") {
866 found = true
867 }
868 }
869 if !found {
870 t.Fatalf("events = %v, want a rollback row whose detail names the CLI", events)
871 }
872}
873
874// TestAPIRollbackStableRefused: Paul's ruling (2026-08-03) — a rollback that
875// would land on stable is refused with the SAME byte-identical 403 as a
876// stable promote, for a member token and an owner token alike, and the
877// pointer it would have touched is left exactly as it was.
878func TestAPIRollbackStableRefused(t *testing.T) {
879 fx := newConsoleFixture(t)
880 now := time.Now()
881 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
882 t.Fatal(err)
883 }
884 shipVersion(t, fx, "titogo", "abc1234")
885 shipVersion(t, fx, "titogo", "def5678")
886 // A stable channel with a real history to roll back to — so a refusal
887 // that let the rollback through would be visible in what "stable" points
888 // at afterwards, not just in a channel that never existed.
889 seedChannel(t, fx, "titogo", "stable", "abc1234")
890 seedChannel(t, fx, "titogo", "stable", "def5678")
891 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
892 t.Fatal(err)
893 }
894 owner := cliToken(t, fx, "paul@keymail.dev", "code-rollback-stable-owner")
895 member := cliToken(t, fx, "alice@keymail.dev", "code-rollback-stable-member")
896
897 body := map[string]string{"channel": "stable"}
898 statusO, outO := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/rollback", body, owner)
899 statusM, outM := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/rollback", body, member)
900 if statusO != http.StatusForbidden || statusM != http.StatusForbidden {
901 t.Fatalf("stable rollback = owner %d, member %d, want both 403", statusO, statusM)
902 }
903 if outO != outM {
904 t.Fatalf("stable rollback refusal not byte-identical:\nowner: %q\nmember: %q", outO, outM)
905 }
906 wantURL := fx.ts.URL + "/a/bab/apps/titogo"
907 if !strings.Contains(outO, wantURL) {
908 t.Fatalf("stable rollback refusal = %q, want console_url %q", outO, wantURL)
909 }
910 if !strings.Contains(outO, "passkey") {
911 t.Fatalf("stable rollback refusal = %q, want it to name the passkey requirement", outO)
912 }
913 if cp := fx.channelVersion(t, "titogo", "stable"); cp.Version != "def5678" {
914 t.Fatalf("stable channel after two refused rollbacks = %q, want def5678 (unchanged)", cp.Version)
915 }
916}
917
918// ---- ship (apicli.go) -------------------------------------------------------
919//
920// `carlos ship`'s door: a multipart upload whose bytes are hashed and sized
921// HERE, server-side, from what actually arrived on the wire — never from a
922// field a caller could set, because there is no such field on this wire
923// shape (the meta part carries only version and kind).
924
925// shipPart is one part of a multipart ship request, in the order it should
926// be written — order matters, since the handler insists "meta" come first.
927type shipPart struct {
928 name string
929 body []byte
930}
931
932// metaPart builds the JSON "meta" part every ship request starts with.
933func metaPart(t *testing.T, version, kind string) shipPart {
934 t.Helper()
935 raw, err := json.Marshal(map[string]string{"version": version, "kind": kind})
936 if err != nil {
937 t.Fatal(err)
938 }
939 return shipPart{name: "meta", body: raw}
940}
941
942// metaPartFull is metaPart with label and notes attached, and a version that
943// may be empty — the shape a target-minting ship (release versioning spec)
944// puts on the wire, alongside the label a human types at ship time.
945func metaPartFull(t *testing.T, version, kind, label, notes string) shipPart {
946 t.Helper()
947 raw, err := json.Marshal(map[string]string{
948 "version": version, "kind": kind, "label": label, "notes": notes,
949 })
950 if err != nil {
951 t.Fatal(err)
952 }
953 return shipPart{name: "meta", body: raw}
954}
955
956// randomBytes is a stand-in artifact payload: real bytes, not a fixed
957// string, so a hash mismatch would actually be caught rather than a lucky
958// coincidence.
959func randomBytes(t *testing.T, n int) []byte {
960 t.Helper()
961 b := make([]byte, n)
962 if _, err := rand.Read(b); err != nil {
963 t.Fatal(err)
964 }
965 return b
966}
967
968// liftShipBudget takes the ship rate limiter out of a test's way.
969//
970// cliShipBurst is 2 a minute (authcli.go) — deliberately small, because the
971// caller it bounds is a release upload. A test that ships three or more
972// times is almost always testing something else entirely (immutability,
973// minting, the capability vocabulary, the concurrency semaphore) and would
974// otherwise be refused 429 partway through by a gate it is not about. Worse
975// for the semaphore test: the rate limiter sits IN FRONT of the semaphore,
976// so without this the 429 it asserts on would be the wrong 429 and the test
977// would prove nothing.
978//
979// The budget itself is pinned elsewhere and stays pinned:
980// TestPersonShipBudgetStaysKeyedByToken and
981// TestServiceShipBudgetIsOwnAndKeyedByName (authcli_test.go) run against the
982// real constants and must never call this.
983func liftShipBudget(fx *consoleFixture) {
984 fx.srv.cli.ship = newRateLimiter(1<<20, time.Minute)
985 fx.srv.cli.svcShip = newRateLimiter(1<<20, time.Minute)
986}
987
988// postShip POSTs a multipart/form-data ship request built from parts, in
989// the order given, and decodes whatever JSON comes back (a refusal that
990// never reaches the handler's own JSON responses — e.g. a body cut short by
991// MaxBytesReader — decodes to an empty map, which the caller only inspects
992// via the status code).
993func postShip(t *testing.T, u, bearer string, parts []shipPart) (int, map[string]any) {
994 t.Helper()
995 status, _, out := postShipRaw(t, u, bearer, parts)
996 return status, out
997}
998
999// postShipRaw is postShip with the response HEADERS kept — the refusal that
1000// needs them is the 429, whose Retry-After is the whole point of the answer
1001// for a non-human caller and is invisible in the JSON body.
1002func postShipRaw(t *testing.T, u, bearer string, parts []shipPart) (int, http.Header, map[string]any) {
1003 t.Helper()
1004 var buf bytes.Buffer
1005 mw := multipart.NewWriter(&buf)
1006 for _, p := range parts {
1007 fw, err := mw.CreateFormFile(p.name, p.name)
1008 if err != nil {
1009 t.Fatal(err)
1010 }
1011 if _, err := fw.Write(p.body); err != nil {
1012 t.Fatal(err)
1013 }
1014 }
1015 if err := mw.Close(); err != nil {
1016 t.Fatal(err)
1017 }
1018 req, err := http.NewRequest(http.MethodPost, u, &buf)
1019 if err != nil {
1020 t.Fatal(err)
1021 }
1022 req.Header.Set("Content-Type", mw.FormDataContentType())
1023 if bearer != "" {
1024 req.Header.Set("Authorization", "Bearer "+bearer)
1025 }
1026 res, err := http.DefaultClient.Do(req)
1027 if err != nil {
1028 t.Fatal(err)
1029 }
1030 defer res.Body.Close()
1031 raw, err := io.ReadAll(res.Body)
1032 if err != nil {
1033 t.Fatal(err)
1034 }
1035 out := map[string]any{}
1036 _ = json.Unmarshal(raw, &out)
1037 return res.StatusCode, res.Header, out
1038}
1039
1040// TestAPIShipHappy: a fresh binary release ships through the multipart
1041// door, lands at the exact bucket key the direct-to-bucket path would use,
1042// and the manifest the server writes carries hashes/sizes it computed
1043// itself — matching what the artifact bytes actually hash to, and BuiltBy
1044// naming the console API as the shipper.
1045func TestAPIShipHappy(t *testing.T) {
1046 fx := newConsoleFixture(t)
1047 now := time.Now()
1048 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
1049 t.Fatal(err)
1050 }
1051 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-happy")
1052 payload := randomBytes(t, 1024)
1053
1054 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
1055 []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}})
1056 if status != http.StatusCreated {
1057 t.Fatalf("ship = %d %v", status, out)
1058 }
1059 if str(t, out, "result") != "shipped" || str(t, out, "version") != "abc1234" {
1060 t.Fatalf("ship body = %v", out)
1061 }
1062 sum := sha256.Sum256(payload)
1063 wantSHA := hex.EncodeToString(sum[:])
1064 arts, _ := out["artifacts"].(map[string]any)
1065 arm64, _ := arts["linux-arm64"].(map[string]any)
1066 if arm64 == nil {
1067 t.Fatalf("ship artifacts = %v, want linux-arm64", arts)
1068 }
1069 if str(t, arm64, "sha256") != wantSHA {
1070 t.Fatalf("artifact sha256 = %v, want %s (computed from the bytes sent)", arm64["sha256"], wantSHA)
1071 }
1072 if sz, _ := arm64["size"].(float64); int64(sz) != int64(len(payload)) {
1073 t.Fatalf("artifact size = %v, want %d", arm64["size"], len(payload))
1074 }
1075
1076 ctx := context.Background()
1077 stored, _, err := fx.rel.Get(ctx, release.ArtifactKey(release.OpsAccount, "titogo", "abc1234", "linux-arm64"))
1078 if err != nil {
1079 t.Fatalf("stored artifact bytes: %v", err)
1080 }
1081 if !bytes.Equal(stored, payload) {
1082 t.Fatalf("stored artifact bytes differ from what was shipped")
1083 }
1084 m, err := release.GetManifest(ctx, fx.rel, release.OpsAccount, "titogo", "abc1234")
1085 if err != nil {
1086 t.Fatalf("manifest: %v", err)
1087 }
1088 a, ok := m.Artifacts["linux-arm64"]
1089 if !ok {
1090 t.Fatalf("manifest artifacts = %v, want linux-arm64", m.Artifacts)
1091 }
1092 if a.SHA256 != wantSHA || a.Size != int64(len(payload)) {
1093 t.Fatalf("manifest artifact = %+v, want sha256=%s size=%d", a, wantSHA, len(payload))
1094 }
1095 if !strings.HasPrefix(m.BuiltBy, "console-api ") {
1096 t.Fatalf("manifest built_by = %q, want prefix %q", m.BuiltBy, "console-api ")
1097 }
1098
1099 events, err := fx.st.Events(OpsAccountID, "titogo", 10)
1100 if err != nil {
1101 t.Fatal(err)
1102 }
1103 found := false
1104 for _, e := range events {
1105 if e.Action == "ship" && strings.Contains(e.Detail, "(cli)") {
1106 found = true
1107 }
1108 }
1109 if !found {
1110 t.Fatalf("events = %v, want a ship row whose detail names the CLI", events)
1111 }
1112}
1113
1114// TestAPIShipStatic: a static site release ships under the single
1115// "site.tar.gz" artifact name the static kind requires.
1116func TestAPIShipStatic(t *testing.T) {
1117 fx := newConsoleFixture(t)
1118 now := time.Now()
1119 if _, err := fx.st.CreateAppIn(OpsAccountID, "website", "paul@keymail.dev", now); err != nil {
1120 t.Fatal(err)
1121 }
1122 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-static")
1123 payload := randomBytes(t, 512)
1124
1125 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/website/releases", token,
1126 []shipPart{metaPart(t, "def5678", "static"), {name: "site.tar.gz", body: payload}})
1127 if status != http.StatusCreated {
1128 t.Fatalf("ship static = %d %v", status, out)
1129 }
1130 arts, _ := out["artifacts"].(map[string]any)
1131 if _, ok := arts["site.tar.gz"]; !ok {
1132 t.Fatalf("ship static artifacts = %v, want site.tar.gz", arts)
1133 }
1134}
1135
1136// TestAPIShipRefusals: every malformed shape of a ship request is a 400,
1137// and none of them reach release.Ship.
1138func TestAPIShipRefusals(t *testing.T) {
1139 fx := newConsoleFixture(t)
1140 now := time.Now()
1141 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
1142 t.Fatal(err)
1143 }
1144 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-refusals")
1145 liftShipBudget(fx)
1146 payload := randomBytes(t, 128)
1147
1148 cases := []struct {
1149 name string
1150 parts []shipPart
1151 }{
1152 {"bad version chars", []shipPart{
1153 metaPart(t, "../etc/passwd", "binary"), {name: "linux-arm64", body: payload}}},
1154 {"bad kind", []shipPart{
1155 metaPart(t, "abc1234", "weird"), {name: "linux-arm64", body: payload}}},
1156 {"binary part not a target name", []shipPart{
1157 metaPart(t, "abc1234", "binary"), {name: "not-a-target", body: payload}}},
1158 {"static part not site.tar.gz", []shipPart{
1159 metaPart(t, "abc1234", "static"), {name: "linux-arm64", body: payload}}},
1160 {"no file parts", []shipPart{
1161 metaPart(t, "abc1234", "binary")}},
1162 {"meta not first part", []shipPart{
1163 {name: "linux-arm64", body: payload}, metaPart(t, "abc1234", "binary")}},
1164 }
1165 for _, c := range cases {
1166 t.Run(c.name, func(t *testing.T) {
1167 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, c.parts)
1168 if status != http.StatusBadRequest {
1169 t.Fatalf("%s = %d %v, want 400", c.name, status, out)
1170 }
1171 })
1172 }
1173}
1174
1175// TestAPIShipTooBig: apiShipMaxBytes bounds the WHOLE request, not any one
1176// part — lowered here to 4KiB, an 8KiB artifact part is refused with 413
1177// before it is ever handed to release.Ship.
1178func TestAPIShipTooBig(t *testing.T) {
1179 old := apiShipMaxBytes
1180 apiShipMaxBytes = 4 << 10
1181 defer func() { apiShipMaxBytes = old }()
1182
1183 fx := newConsoleFixture(t)
1184 now := time.Now()
1185 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
1186 t.Fatal(err)
1187 }
1188 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-toobig")
1189 payload := randomBytes(t, 8<<10)
1190
1191 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
1192 []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}})
1193 if status != http.StatusRequestEntityTooLarge {
1194 t.Fatalf("oversized ship = %d %v, want 413", status, out)
1195 }
1196}
1197
1198// TestAPIShipMemberGate: a non-member token gets the identical 404 every
1199// other CLI-API door gives an unknown or someone-else's account — apiAccountApp
1200// runs before any multipart parsing, so a garbage body never matters here.
1201func TestAPIShipMemberGate(t *testing.T) {
1202 fx := newConsoleFixture(t)
1203 token := cliToken(t, fx, "eve@keymail.dev", "code-ship-gate")
1204
1205 status, body := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", map[string]string{}, token)
1206 if status != http.StatusNotFound {
1207 t.Fatalf("ship as non-member = %d, want 404", status)
1208 }
1209 if body != "404 page not found\n" {
1210 t.Fatalf("ship as non-member body = %q, want the stdlib http.NotFound output", body)
1211 }
1212}
1213
1214// TestAPIShipTooManyParts: apiShipMaxBytes bounds bytes, not COUNT — a body
1215// of nothing but tiny, validly-named parts would otherwise fit millions of
1216// them in one small request. The ninth binary part (max is
1217// apiShipMaxPartsBinary=8) is refused before it is ever read, and nothing
1218// from this request reaches release.Ship: no manifest for this version
1219// exists afterward.
1220func TestAPIShipTooManyParts(t *testing.T) {
1221 fx := newConsoleFixture(t)
1222 now := time.Now()
1223 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
1224 t.Fatal(err)
1225 }
1226 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-toomanyparts")
1227
1228 parts := []shipPart{metaPart(t, "abc1234", "binary")}
1229 for i := 0; i < 9; i++ {
1230 parts = append(parts, shipPart{name: fmt.Sprintf("part-%d", i), body: []byte("x")})
1231 }
1232 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, parts)
1233 if status != http.StatusBadRequest {
1234 t.Fatalf("9-part ship = %d %v, want 400", status, out)
1235 }
1236 if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "abc1234"); err == nil {
1237 t.Fatalf("manifest exists after a refused too-many-parts ship, want none — nothing should reach release.Ship")
1238 }
1239}
1240
1241// TestAPIShipDuplicatePart: two parts claiming the same artifact name are
1242// refused rather than letting the second silently win — both would
1243// otherwise be charged against the byte and part-count budgets while only
1244// the last one's bytes ever reached the manifest.
1245func TestAPIShipDuplicatePart(t *testing.T) {
1246 fx := newConsoleFixture(t)
1247 now := time.Now()
1248 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
1249 t.Fatal(err)
1250 }
1251 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-duplicate")
1252
1253 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
1254 []shipPart{
1255 metaPart(t, "abc1234", "binary"),
1256 {name: "linux-arm64", body: randomBytes(t, 32)},
1257 {name: "linux-arm64", body: randomBytes(t, 32)},
1258 })
1259 if status != http.StatusBadRequest {
1260 t.Fatalf("duplicate-named ship = %d %v, want 400", status, out)
1261 }
1262 if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "abc1234"); err == nil {
1263 t.Fatalf("manifest exists after a refused duplicate-part ship, want none")
1264 }
1265}
1266
1267// TestAPIShipTraversalRefusals: every part name that reads like an attempt
1268// to escape the artifact's bucket-key segment is refused with 400, and — the
1269// point of this test, not just its status code — none of them leave any
1270// trace in the release store. Part names become bucket key segments via the
1271// manifest (release.ArtifactKey joins them straight into the object path),
1272// so this is path-traversal defense, not tidiness.
1273func TestAPIShipTraversalRefusals(t *testing.T) {
1274 fx := newConsoleFixture(t)
1275 now := time.Now()
1276 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
1277 t.Fatal(err)
1278 }
1279 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-traversal")
1280 liftShipBudget(fx)
1281 payload := randomBytes(t, 64)
1282
1283 cases := []struct {
1284 name string
1285 version string
1286 kind string
1287 part string
1288 }{
1289 {"dotdot slash etc passwd", "trv00001", "binary", "../../etc/passwd"},
1290 {"nested traversal under a valid-looking prefix", "trv00002", "binary", "linux-arm64/../../x"},
1291 {"bare dotdot", "trv00003", "binary", ".."},
1292 {"empty part name", "trv00004", "binary", ""},
1293 {"static traversal", "trv00005", "static", "../site.tar.gz"},
1294 }
1295 for _, c := range cases {
1296 t.Run(c.name, func(t *testing.T) {
1297 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
1298 []shipPart{metaPart(t, c.version, c.kind), {name: c.part, body: payload}})
1299 if status != http.StatusBadRequest {
1300 t.Fatalf("%s = %d %v, want 400", c.name, status, out)
1301 }
1302 if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", c.version); err == nil {
1303 t.Fatalf("%s: manifest exists for version %s, want none — release.Ship must never have been reached",
1304 c.name, c.version)
1305 }
1306 })
1307 }
1308}
1309
1310// TestAPIShipImmutableConflict: re-shipping an already-shipped version with
1311// DIFFERENT bytes is a permanent 409, not the generic 502 every other
1312// release.Ship failure gets — a caller retrying a 502 would re-upload
1313// forever and never learn this can't succeed. Re-shipping the exact same
1314// bytes, by contrast, must stay a success: it is the one case release.Ship
1315// itself calls idempotent, and a caller retrying a dropped connection needs
1316// that retry to actually land.
1317func TestAPIShipImmutableConflict(t *testing.T) {
1318 fx := newConsoleFixture(t)
1319 now := time.Now()
1320 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
1321 t.Fatal(err)
1322 }
1323 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-immutable")
1324 liftShipBudget(fx)
1325 payload := randomBytes(t, 256)
1326
1327 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
1328 []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}})
1329 if status != http.StatusCreated {
1330 t.Fatalf("first ship = %d %v", status, out)
1331 }
1332
1333 different := randomBytes(t, 256)
1334 status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
1335 []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: different}})
1336 if status != http.StatusConflict {
1337 t.Fatalf("re-ship with different bytes = %d %v, want 409", status, out)
1338 }
1339 if !strings.Contains(str(t, out, "error"), "immutable") {
1340 t.Fatalf("conflict error = %q, want it to name immutability", str(t, out, "error"))
1341 }
1342
1343 status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
1344 []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}})
1345 if status != http.StatusOK {
1346 t.Fatalf("re-ship with identical bytes = %d %v, want 200 (idempotent, nothing created)", status, out)
1347 }
1348 if str(t, out, "result") != "shipped" || str(t, out, "version") != "abc1234" {
1349 t.Fatalf("idempotent re-ship body = %v", out)
1350 }
1351}
1352
1353// tinyProgramForConsole is internal/release's tinyProgram, copied because a
1354// test helper cannot be imported across packages: a stripped linux/arm64 Go
1355// binary whose main package is modPath, built as the console is built, at
1356// test time. Nothing is checked in.
1357func tinyProgramForConsole(t *testing.T, modPath string) []byte {
1358 t.Helper()
1359 if testing.Short() {
1360 t.Skip("builds a binary")
1361 }
1362 dir := t.TempDir()
1363 must := func(err error) {
1364 t.Helper()
1365 if err != nil {
1366 t.Fatal(err)
1367 }
1368 }
1369 must(os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module "+modPath+"\n\ngo 1.22\n"), 0o644))
1370 must(os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\nfunc main() {}\n"), 0o644))
1371 out := filepath.Join(dir, "bin")
1372 cmd := exec.Command("go", "build", "-trimpath", "-ldflags=-s -w", "-o", out, ".")
1373 cmd.Dir = dir
1374 cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=arm64", "CGO_ENABLED=0", "GOFLAGS=-buildvcs=false")
1375 if b, err := cmd.CombinedOutput(); err != nil {
1376 t.Fatalf("build %s: %v\n%s", modPath, err, b)
1377 }
1378 b, err := os.ReadFile(out)
1379 must(err)
1380 return b
1381}
1382
1383// TestAPIShipRefusesAProgramChange: the wrong-program rule at the API door.
1384// A second release whose Go main package differs from the app's previous
1385// release is a 409 naming carlos ship --program-change, and the same ship with the
1386// meta part's program_change set goes through and is recorded.
1387func TestAPIShipRefusesAProgramChange(t *testing.T) {
1388 fx := newConsoleFixture(t)
1389 now := time.Now()
1390 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
1391 t.Fatal(err)
1392 }
1393 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-program")
1394 liftShipBudget(fx)
1395 url := fx.ts.URL + "/api/cli/apps/bab/titogo/releases"
1396 console := tinyProgramForConsole(t, "example.com/console")
1397 carlos := tinyProgramForConsole(t, "example.com/carlos")
1398
1399 status, out := postShip(t, url, token,
1400 []shipPart{metaPart(t, "v1", "binary"), {name: "linux-arm64", body: console}})
1401 if status != http.StatusCreated {
1402 t.Fatalf("first ship = %d %v", status, out)
1403 }
1404
1405 status, out = postShip(t, url, token,
1406 []shipPart{metaPart(t, "v2", "binary"), {name: "linux-arm64", body: carlos}})
1407 if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), "carlos ship --program-change") {
1408 t.Fatalf("second ship = %d %v, want 409 naming carlos ship --program-change", status, out)
1409 }
1410
1411 raw, err := json.Marshal(map[string]any{"version": "v3", "kind": "binary", "program_change": true})
1412 if err != nil {
1413 t.Fatal(err)
1414 }
1415 status, out = postShip(t, url, token,
1416 []shipPart{{name: "meta", body: raw}, {name: "linux-arm64", body: carlos}})
1417 if status != http.StatusCreated {
1418 t.Fatalf("ship with program_change = %d %v, want 201", status, out)
1419 }
1420 m, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "v3")
1421 if err != nil {
1422 t.Fatal(err)
1423 }
1424 if m.Program != "example.com/carlos" || !m.ProgramChange {
1425 t.Fatalf("manifest program = %q change = %v, want example.com/carlos recorded as a change", m.Program, m.ProgramChange)
1426 }
1427}
1428
1429// TestAPIShipReshipAfterAProgramChangeStaysIdempotent: the wrong-program rule
1430// judges only a NEW version. Re-shipping v1 with identical bytes after the app
1431// moved on to another program (v2, shipped with program_change) is still the
1432// idempotent 200, not a 409 against v2's program.
1433func TestAPIShipReshipAfterAProgramChangeStaysIdempotent(t *testing.T) {
1434 fx := newConsoleFixture(t)
1435 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
1436 t.Fatal(err)
1437 }
1438 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-program-reship")
1439 liftShipBudget(fx)
1440 url := fx.ts.URL + "/api/cli/apps/bab/titogo/releases"
1441 console := tinyProgramForConsole(t, "example.com/console")
1442 carlos := tinyProgramForConsole(t, "example.com/carlos")
1443
1444 status, out := postShip(t, url, token,
1445 []shipPart{metaPart(t, "v1", "binary"), {name: "linux-arm64", body: console}})
1446 if status != http.StatusCreated {
1447 t.Fatalf("v1 = %d %v", status, out)
1448 }
1449 raw, err := json.Marshal(map[string]any{"version": "v2", "kind": "binary", "program_change": true})
1450 if err != nil {
1451 t.Fatal(err)
1452 }
1453 status, out = postShip(t, url, token,
1454 []shipPart{{name: "meta", body: raw}, {name: "linux-arm64", body: carlos}})
1455 if status != http.StatusCreated {
1456 t.Fatalf("v2 with program_change = %d %v", status, out)
1457 }
1458 status, out = postShip(t, url, token,
1459 []shipPart{metaPart(t, "v1", "binary"), {name: "linux-arm64", body: console}})
1460 if status != http.StatusOK {
1461 t.Fatalf("identical re-ship of v1 = %d %v, want 200 (idempotent)", status, out)
1462 }
1463}
1464
1465// TestAPIShipConcurrencyLimit: the rate limiter bounds requests over TIME,
1466// but says nothing about how many run AT ONCE. Two ships already in flight
1467// (each holding the server's ship semaphore while its body trickles in,
1468// via an io.Pipe under the test's control) leave a third concurrent
1469// request no room — it must be refused immediately with 429, not queued
1470// behind either of the first two.
1471func TestAPIShipConcurrencyLimit(t *testing.T) {
1472 fx := newConsoleFixture(t)
1473 now := time.Now()
1474 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
1475 t.Fatal(err)
1476 }
1477 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-concurrency")
1478 liftShipBudget(fx)
1479
1480 // A hook (same shape as cliNotified): signalled once a request has
1481 // actually acquired shipSem, so this test waits on a real fact instead
1482 // of racing the two background goroutines below with a sleep.
1483 fx.srv.shipStarted = make(chan struct{}, 2)
1484
1485 var wg sync.WaitGroup
1486 statuses := make([]int, 2)
1487 var closers []func() error
1488 for i := 0; i < 2; i++ {
1489 pr, pw := io.Pipe()
1490 closers = append(closers, pw.Close)
1491 req, err := http.NewRequest(http.MethodPost, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", pr)
1492 if err != nil {
1493 t.Fatal(err)
1494 }
1495 req.Header.Set("Content-Type", "multipart/form-data; boundary=xshipboundaryx")
1496 req.Header.Set("Authorization", "Bearer "+token)
1497 wg.Add(1)
1498 idx := i
1499 go func() {
1500 defer wg.Done()
1501 res, err := http.DefaultClient.Do(req)
1502 if err != nil {
1503 statuses[idx] = -1
1504 return
1505 }
1506 defer res.Body.Close()
1507 io.Copy(io.Discard, res.Body)
1508 statuses[idx] = res.StatusCode
1509 }()
1510 }
1511
1512 for i := 0; i < 2; i++ {
1513 select {
1514 case <-fx.srv.shipStarted:
1515 case <-time.After(5 * time.Second):
1516 t.Fatal("timed out waiting for the first two ships to start (acquire shipSem)")
1517 }
1518 }
1519
1520 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
1521 []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}})
1522 if status != http.StatusTooManyRequests {
1523 t.Fatalf("third concurrent ship = %d %v, want 429", status, out)
1524 }
1525 if !strings.Contains(str(t, out, "error"), "in progress") {
1526 t.Fatalf("third concurrent ship error = %q, want it to name the concurrency limit", str(t, out, "error"))
1527 }
1528
1529 // Gate ORDER, pinned while the semaphore is still exhausted: a request
1530 // whose capability block is garbage must still be refused 429, not 400.
1531 // The capability decode was added inside the handler, AFTER the member
1532 // gate, the rate limiter and this semaphore — and it has to stay there. A
1533 // validation step that ran before them would let an unauthenticated or
1534 // over-budget caller learn things about this server's capability
1535 // vocabulary by feeding it blocks, and would do the work of decoding
1536 // them, which is precisely what the resource gates exist to refuse.
1537 status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
1538 []shipPart{metaPartCaps(t, "abc1234", "binary", `{"v":1,"daemon":true,"gpu":true}`),
1539 {name: "linux-arm64", body: randomBytes(t, 64)}})
1540 if status != http.StatusTooManyRequests {
1541 t.Fatalf("bad-capabilities ship under an exhausted semaphore = %d %v, want 429 — the caps decode must sit AFTER the resource gates", status, out)
1542 }
1543
1544 // Unblock the two held-open requests (each now sees an immediately-EOF
1545 // body and is refused as an empty upload) so the test doesn't leak
1546 // goroutines, and confirm they actually finish.
1547 for _, c := range closers {
1548 c()
1549 }
1550 done := make(chan struct{})
1551 go func() { wg.Wait(); close(done) }()
1552 select {
1553 case <-done:
1554 case <-time.After(5 * time.Second):
1555 t.Fatal("timed out waiting for the held-open ships to finish after closing their bodies")
1556 }
1557 for i, st := range statuses {
1558 if st != http.StatusBadRequest {
1559 t.Fatalf("held-open ship %d finished with status %d, want 400 (empty upload, once unblocked)", i, st)
1560 }
1561 }
1562}
1563
1564// metaPartCaps is metaPart with an app's capability declaration attached —
1565// the shape `carlos ship -capabilities <file>` puts on the wire. capsJSON is
1566// spliced in raw so a test can send a block the strict decoder must refuse,
1567// which a typed caps.Set could not even express.
1568func metaPartCaps(t *testing.T, version, kind, capsJSON string) shipPart {
1569 t.Helper()
1570 raw, err := json.Marshal(map[string]any{
1571 "version": version, "kind": kind, "capabilities": json.RawMessage(capsJSON),
1572 })
1573 if err != nil {
1574 t.Fatal(err)
1575 }
1576 return shipPart{name: "meta", body: raw}
1577}
1578
1579// TestAPIShipCarriesCapabilitiesIntoTheManifest: the declaration rides the
1580// meta part and lands in the immutable manifest, decoded through caps's own
1581// strict door. This is the console half of the ship-time declaration surface;
1582// the direct CLI path's half is readCapabilities (cli.go).
1583func TestAPIShipCarriesCapabilitiesIntoTheManifest(t *testing.T) {
1584 fx := newConsoleFixture(t)
1585 now := time.Now()
1586 if _, err := fx.st.CreateAppIn(OpsAccountID, "keymail", "paul@keymail.dev", now); err != nil {
1587 t.Fatal(err)
1588 }
1589 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-caps")
1590 payload := randomBytes(t, 128)
1591
1592 const block = `{"v":1,"command":"relay","daemon":true,"children":true,` +
1593 `"runtime_dir":"keymail","ports":[{"port":25,"name":"mx"}],` +
1594 `"data":{"replicate":"app"},"host_role":true}`
1595 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/keymail/releases", token,
1596 []shipPart{metaPartCaps(t, "cap01234", "binary", block), {name: "linux-arm64", body: payload}})
1597 if status != http.StatusCreated {
1598 t.Fatalf("ship with capabilities = %d %v", status, out)
1599 }
1600 m, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "keymail", "cap01234")
1601 if err != nil {
1602 t.Fatalf("manifest: %v", err)
1603 }
1604 if m.Capabilities == nil {
1605 t.Fatal("the manifest carries no capability block")
1606 }
1607 c := m.Capabilities
1608 if c.V != 1 || c.Command != "relay" || !c.Daemon || !c.Children || c.RuntimeDir != "keymail" || !c.HostRole {
1609 t.Fatalf("capabilities = %+v, want the declared block", c)
1610 }
1611 if len(c.Ports) != 1 || c.Ports[0].Port != 25 || c.Ports[0].Name != "mx" {
1612 t.Fatalf("capabilities ports = %+v, want [{25 mx}]", c.Ports)
1613 }
1614 if c.Data == nil || c.Data.Replicate != caps.DataReplicateApp {
1615 t.Fatalf("capabilities data = %+v, want replicate=app", c.Data)
1616 }
1617}
1618
1619// TestAPIShipRefusesABadCapabilityBlock: everything ValidCapabilities and the
1620// strict decoder refuse is refused HERE, at ship, with the caps message
1621// itself — a 400 that names the offending capability, not a generic
1622// "malformed meta part". And release.Ship is never reached, so no manifest
1623// exists afterwards for the box to trip over.
1624func TestAPIShipRefusesABadCapabilityBlock(t *testing.T) {
1625 fx := newConsoleFixture(t)
1626 now := time.Now()
1627 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
1628 t.Fatal(err)
1629 }
1630 payload := randomBytes(t, 128)
1631
1632 cases := []struct {
1633 name string
1634 version string
1635 block string
1636 names string // a substring the refusal must contain
1637 }{
1638 {"unknown capability name", "cap10001", `{"v":1,"daemon":true,"gpu":true}`, "gpu"},
1639 {"unsupported block version", "cap10002", `{"v":2,"daemon":true}`, "update the platform binary"},
1640 {"missing v", "cap10003", `{"daemon":true}`, `"v"`},
1641 {"non-privileged port", "cap10004", `{"v":1,"ports":[{"port":8080}]}`, "8080"},
1642 {"reserved port", "cap10005", `{"v":1,"ports":[{"port":443}]}`, "443"},
1643 {"reserved runtime_dir", "cap10006", `{"v":1,"runtime_dir":"carlos"}`, "carlos"},
1644 {"runtime_dir traversal", "cap10007", `{"v":1,"runtime_dir":"../etc"}`, "runtime_dir"},
1645 {"dbs with replicate app", "cap10008", `{"v":1,"data":{"replicate":"app","dbs":["x.db"]}}`, "dbs"},
1646 {"db name with a separator", "cap10009", `{"v":1,"data":{"replicate":"platform","dbs":["a/b.db"]}}`, "a/b.db"},
1647 {"explicit null", "cap10010", `null`, "null"},
1648 }
1649 // One token, with the ship budget lifted: this table is about the
1650 // capability vocabulary, and every case has to reach the decoder to pin
1651 // anything. It used to be capped at cliShipBurst cases instead, which
1652 // silently made the table's length a function of a rate limit that has
1653 // since moved.
1654 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-badcaps")
1655 liftShipBudget(fx)
1656 for _, c := range cases {
1657 t.Run(c.name, func(t *testing.T) {
1658 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
1659 []shipPart{metaPartCaps(t, c.version, "binary", c.block), {name: "linux-arm64", body: payload}})
1660 if status != http.StatusBadRequest {
1661 t.Fatalf("%s = %d %v, want 400", c.name, status, out)
1662 }
1663 msg := str(t, out, "error")
1664 if !strings.Contains(msg, c.names) {
1665 t.Fatalf("%s: refusal %q does not contain %q", c.name, msg, c.names)
1666 }
1667 // No refusal on this path may leak an internal Go type name — a
1668 // caller cannot act on one, and it is not this API's vocabulary.
1669 if strings.Contains(msg, "caps.Set") || strings.Contains(msg, "json:") {
1670 t.Fatalf("%s: refusal %q leaks the Go decoder's own message", c.name, msg)
1671 }
1672 if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", c.version); err == nil {
1673 t.Fatalf("%s: a manifest exists for %s — release.Ship must never have been reached", c.name, c.version)
1674 }
1675 })
1676 }
1677}
1678
1679// TestAPIShipRefusesANonObjectCapabilityBlock: a "capabilities" value that is
1680// not an object at all never reaches caps's own vocabulary — encoding/json
1681// refuses it first, and ITS message names an internal Go type ("json: cannot
1682// unmarshal number into Go value of type caps.Set"), carries no prefix, and
1683// tells the caller nothing they can act on. It is replaced with one fixed
1684// line in the same register as this handler's other 400s ("malformed meta
1685// part"). Pinned so the raw decoder error can never leak back into the API's
1686// vocabulary.
1687func TestAPIShipRefusesANonObjectCapabilityBlock(t *testing.T) {
1688 fx := newConsoleFixture(t)
1689 now := time.Now()
1690 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
1691 t.Fatal(err)
1692 }
1693 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-nonobject")
1694 liftShipBudget(fx)
1695 payload := randomBytes(t, 128)
1696
1697 for _, c := range []struct{ name, version, block string }{
1698 {"a number", "cap20001", `7`},
1699 {"a string", "cap20002", `"daemon"`},
1700 {"an array", "cap20003", `[{"v":1}]`},
1701 } {
1702 t.Run(c.name, func(t *testing.T) {
1703 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
1704 []shipPart{metaPartCaps(t, c.version, "binary", c.block), {name: "linux-arm64", body: payload}})
1705 if status != http.StatusBadRequest {
1706 t.Fatalf("%s = %d %v, want 400", c.name, status, out)
1707 }
1708 msg := str(t, out, "error")
1709 if !strings.Contains(msg, "malformed capabilities block") {
1710 t.Fatalf("%s: refusal = %q, want the fixed malformed-block line", c.name, msg)
1711 }
1712 if strings.Contains(msg, "caps.Set") || strings.Contains(msg, "json:") {
1713 t.Fatalf("%s: refusal %q leaks the Go decoder's own message", c.name, msg)
1714 }
1715 if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", c.version); err == nil {
1716 t.Fatalf("%s: a manifest exists for %s — release.Ship must never have been reached", c.name, c.version)
1717 }
1718 })
1719 }
1720}
1721
1722// TestAPIShipRefusesARecapabilitiedReship closes the hole the plan named: the
1723// "identical content already shipped" short-circuit compares ARTIFACTS, so
1724// without a capability comparison a second ship of the same version with a
1725// DIFFERENT block would answer 200 and silently keep the FIRST block — the
1726// immutability promise broken for exactly the field that decides which user
1727// the app runs as and which privileged ports it binds.
1728//
1729// All four transitions are pinned, because three of them are the ones a
1730// mistake actually takes: adding a block, changing one, and removing one.
1731// Re-shipping the SAME block stays the idempotent 200 it has to be, or a
1732// caller retrying a dropped connection could never land.
1733func TestAPIShipRefusesARecapabilitiedReship(t *testing.T) {
1734 fx := newConsoleFixture(t)
1735 now := time.Now()
1736 if _, err := fx.st.CreateAppIn(OpsAccountID, "keymail", "paul@keymail.dev", now); err != nil {
1737 t.Fatal(err)
1738 }
1739 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-recaps")
1740 liftShipBudget(fx)
1741 payload := randomBytes(t, 256)
1742 u := fx.ts.URL + "/api/cli/apps/bab/keymail/releases"
1743
1744 const first = `{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}]}`
1745 status, out := postShip(t, u, token,
1746 []shipPart{metaPartCaps(t, "recap001", "binary", first), {name: "linux-arm64", body: payload}})
1747 if status != http.StatusCreated {
1748 t.Fatalf("first ship = %d %v", status, out)
1749 }
1750
1751 // Same bytes, same version, a DIFFERENT block: 409, naming capabilities.
1752 const changed = `{"v":1,"daemon":true,"ports":[{"port":587,"name":"submission"}]}`
1753 status, out = postShip(t, u, token,
1754 []shipPart{metaPartCaps(t, "recap001", "binary", changed), {name: "linux-arm64", body: payload}})
1755 if status != http.StatusConflict {
1756 t.Fatalf("re-ship with different capabilities = %d %v, want 409", status, out)
1757 }
1758 if msg := str(t, out, "error"); !strings.Contains(msg, "capabilities") || !strings.Contains(msg, "immutable") {
1759 t.Fatalf("conflict error = %q, want it to name capabilities and immutability", msg)
1760 }
1761
1762 // Same bytes, no block at all: also a 409 — dropping a declaration is as
1763 // much a change of what the version means as adding one.
1764 status, out = postShip(t, u, token,
1765 []shipPart{metaPart(t, "recap001", "binary"), {name: "linux-arm64", body: payload}})
1766 if status != http.StatusConflict {
1767 t.Fatalf("re-ship with the block removed = %d %v, want 409", status, out)
1768 }
1769
1770 // And the stored block is still the FIRST one: no refused re-ship ever
1771 // rewrote it.
1772 m, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "keymail", "recap001")
1773 if err != nil {
1774 t.Fatalf("manifest: %v", err)
1775 }
1776 if m.Capabilities == nil || len(m.Capabilities.Ports) != 1 || m.Capabilities.Ports[0].Port != 25 {
1777 t.Fatalf("stored capabilities = %+v, want the first block (port 25)", m.Capabilities)
1778 }
1779
1780 // The genuinely identical re-ship is still the idempotent 200.
1781 status, out = postShip(t, u, token,
1782 []shipPart{metaPartCaps(t, "recap001", "binary", first), {name: "linux-arm64", body: payload}})
1783 if status != http.StatusOK {
1784 t.Fatalf("identical re-ship = %d %v, want 200", status, out)
1785 }
1786
1787 // The mirror case: a version shipped with NO block cannot grow one.
1788 status, out = postShip(t, u, token,
1789 []shipPart{metaPart(t, "recap002", "binary"), {name: "linux-arm64", body: payload}})
1790 if status != http.StatusCreated {
1791 t.Fatalf("plain ship = %d %v", status, out)
1792 }
1793 status, out = postShip(t, u, token,
1794 []shipPart{metaPartCaps(t, "recap002", "binary", first), {name: "linux-arm64", body: payload}})
1795 if status != http.StatusConflict {
1796 t.Fatalf("re-ship that ADDS capabilities = %d %v, want 409", status, out)
1797 }
1798}
1799
1800// TestShipCapabilitiesMatchComparesEncodingsNotStructs pins WHY the 409's
1801// comparison is done on canonical JSON: caps.Set carries an unexported notes
1802// slice, so a Set decoded from a block containing an unknown-but-tolerated
1803// field is neither ==-comparable nor reflect.DeepEqual-equal to the same
1804// capabilities expressed without it — even though the two mean identically
1805// the same thing and marshal to identically the same manifest bytes. A
1806// struct comparison here would 409 an honest, idempotent re-ship.
1807func TestShipCapabilitiesMatchComparesEncodingsNotStructs(t *testing.T) {
1808 decode := func(s string) *caps.Set {
1809 t.Helper()
1810 var set *caps.Set
1811 if err := json.Unmarshal([]byte(s), &set); err != nil {
1812 t.Fatalf("decode %s: %v", s, err)
1813 }
1814 return set
1815 }
1816 withNote := decode(`{"v":1,"data":{"replicate":"platform","dbs":["console.db"],"future":7}}`)
1817 plain := decode(`{"v":1,"data":{"replicate":"platform","dbs":["console.db"]}}`)
1818
1819 if len(withNote.Notes()) == 0 {
1820 t.Fatal("fixture no longer produces a note — this test is not testing what it claims")
1821 }
1822 if reflect.DeepEqual(withNote, plain) {
1823 t.Fatal("the two Sets are DeepEqual — the unexported notes hazard is gone and this test can be simplified")
1824 }
1825 if !shipCapabilitiesMatch(withNote, plain) {
1826 t.Fatal("shipCapabilitiesMatch called two blocks that reach the manifest as identical bytes different — an honest re-ship would 409")
1827 }
1828 // List ORDER is significant, and that is the documented, deliberate
1829 // behaviour: the question under immutability is "is this the same
1830 // declaration", and sorting here would let this function decide that two
1831 // manifests an operator can see differ are the same release. Pinned so a
1832 // future "helpful" sort is a red test rather than a silent loosening.
1833 if shipCapabilitiesMatch(
1834 decode(`{"v":1,"ports":[{"port":25},{"port":587}]}`),
1835 decode(`{"v":1,"ports":[{"port":587},{"port":25}]}`)) {
1836 t.Fatal("reordered ports matched — the comparison must stay order-sensitive")
1837 }
1838 if !shipCapabilitiesMatch(nil, nil) {
1839 t.Fatal("two capability-free manifests must match")
1840 }
1841 if shipCapabilitiesMatch(nil, plain) || shipCapabilitiesMatch(plain, nil) {
1842 t.Fatal("nil and a declared block must not match — adding or removing a block changes the version")
1843 }
1844}
1845
1846// TestAPIShipEchoesTheStoredCapabilityBlock is the server half of the removal
1847// signal (2026-08-03 review, I3). A console built before `capabilities`
1848// existed ignores the unknown key — Go's decoder does that by default — stores
1849// a manifest that declares NOTHING, and answers 201; on a box, a manifest that
1850// resolves and declares nothing is the REMOVAL instruction, so the drop-ins go
1851// and :25 comes off a running mail server. The echo is what lets a client tell
1852// the two consoles apart before it promotes anything: this console says back
1853// what it stored.
1854//
1855// Both success paths carry it — the 201 that wrote bytes and the 200 that
1856// matched an identical version already on record — because a re-ship is
1857// exactly as capable of being answered by an old console as a first ship.
1858func TestAPIShipEchoesTheStoredCapabilityBlock(t *testing.T) {
1859 fx := newConsoleFixture(t)
1860 if _, err := fx.st.CreateAppIn(OpsAccountID, "keymail", "paul@keymail.dev", time.Now()); err != nil {
1861 t.Fatal(err)
1862 }
1863 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-echo")
1864 payload := randomBytes(t, 128)
1865 const block = `{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}],"host_role":true}`
1866
1867 parts := func() []shipPart {
1868 return []shipPart{metaPartCaps(t, "echo0001", "binary", block), {name: "linux-arm64", body: payload}}
1869 }
1870 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/keymail/releases", token, parts())
1871 if status != http.StatusCreated {
1872 t.Fatalf("ship with capabilities = %d %v", status, out)
1873 }
1874 want := map[string]any{
1875 "v": float64(1), "daemon": true, "host_role": true,
1876 "ports": []any{map[string]any{"port": float64(25), "name": "mx"}},
1877 }
1878 got, _ := out["capabilities"].(map[string]any)
1879 if !reflect.DeepEqual(got, want) {
1880 t.Fatalf("201 ship echoed capabilities %v, want %v", out["capabilities"], want)
1881 }
1882
1883 // The identical re-ship: 200, nothing written, and the block STILL echoed —
1884 // read back from the stored manifest this time.
1885 status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/keymail/releases", token, parts())
1886 if status != http.StatusOK {
1887 t.Fatalf("identical re-ship = %d %v, want 200", status, out)
1888 }
1889 got, _ = out["capabilities"].(map[string]any)
1890 if !reflect.DeepEqual(got, want) {
1891 t.Fatalf("200 re-ship echoed capabilities %v, want %v", out["capabilities"], want)
1892 }
1893}
1894
1895// TestAPIShipOmitsTheEchoWhenNothingIsDeclared: a version with no capability
1896// block carries no "capabilities" key at all, so the response bytes for every
1897// ship the fleet does today are byte-for-byte what they were. Absence is also
1898// the honest answer — there is no stored block to echo — and it is what the
1899// client keys its refusal off, so a key present-but-null here would defeat the
1900// whole check.
1901func TestAPIShipOmitsTheEchoWhenNothingIsDeclared(t *testing.T) {
1902 fx := newConsoleFixture(t)
1903 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
1904 t.Fatal(err)
1905 }
1906 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-noecho")
1907 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
1908 []shipPart{metaPart(t, "echo0002", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}})
1909 if status != http.StatusCreated {
1910 t.Fatalf("ship = %d %v", status, out)
1911 }
1912 if _, ok := out["capabilities"]; ok {
1913 t.Fatalf("a capability-free ship echoed a capabilities key: %v", out)
1914 }
1915}
1916
1917// TestAPIShipMintsFromTarget: an empty "version" in the meta part is a
1918// refusal naming `carlos version target` when no target is set, and mints
1919// successive iteration versions off the target once one is (release
1920// versioning spec, task 4/1's GetTarget/NextIteration). The label and notes
1921// sent alongside a minted ship land in the stored manifest.
1922func TestAPIShipMintsFromTarget(t *testing.T) {
1923 fx := newConsoleFixture(t)
1924 now := time.Now()
1925 if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil {
1926 t.Fatal(err)
1927 }
1928 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-mint")
1929 liftShipBudget(fx)
1930
1931 // 1. No target set: an empty version is refused, naming the fix.
1932 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/km/releases", token,
1933 []shipPart{metaPart(t, "", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}})
1934 if status != http.StatusBadRequest {
1935 t.Fatalf("empty version, no target = %d %v, want 400", status, out)
1936 }
1937 if !strings.Contains(str(t, out, "error"), "carlos version target") {
1938 t.Fatalf("refusal = %q, want it to name `carlos version target`", str(t, out, "error"))
1939 }
1940
1941 // 2. Set a target.
1942 ctx := context.Background()
1943 if err := release.SetTarget(ctx, fx.rel, release.OpsAccount, "km", "0.5.0", "t", time.Now().UTC()); err != nil {
1944 t.Fatalf("set target: %v", err)
1945 }
1946
1947 // 3. Ship with an empty version, a label and notes: mints 0.5.0-1.
1948 status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/km/releases", token,
1949 []shipPart{metaPartFull(t, "", "binary", "fixing modals", "- x"), {name: "linux-arm64", body: randomBytes(t, 64)}})
1950 if status != http.StatusCreated {
1951 t.Fatalf("minted ship = %d %v, want 201", status, out)
1952 }
1953 if v := str(t, out, "version"); v != "0.5.0-1" {
1954 t.Fatalf("minted version = %q, want 0.5.0-1", v)
1955 }
1956
1957 // 4. The stored manifest carries the label and notes.
1958 m, err := release.GetManifest(ctx, fx.rel, release.OpsAccount, "km", "0.5.0-1")
1959 if err != nil {
1960 t.Fatalf("manifest: %v", err)
1961 }
1962 if m.Label != "fixing modals" || m.Notes != "- x" {
1963 t.Fatalf("manifest label/notes = %q/%q, want %q/%q", m.Label, m.Notes, "fixing modals", "- x")
1964 }
1965
1966 // 5. A second minted ship advances the iteration.
1967 status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/km/releases", token,
1968 []shipPart{metaPart(t, "", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}})
1969 if status != http.StatusCreated {
1970 t.Fatalf("second minted ship = %d %v, want 201", status, out)
1971 }
1972 if v := str(t, out, "version"); v != "0.5.0-2" {
1973 t.Fatalf("second minted version = %q, want 0.5.0-2", v)
1974 }
1975}
1976
1977// TestAPIShipLabelLimits: a label or notes outside release.MaxLabelLen /
1978// MaxNotesLen, or a label spanning more than one line, is refused 400 before
1979// any artifact bytes are stored — the same "refused before the wire cost"
1980// property the capability block already has.
1981func TestAPIShipLabelLimits(t *testing.T) {
1982 fx := newConsoleFixture(t)
1983 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
1984 t.Fatal(err)
1985 }
1986 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-labellimits")
1987 liftShipBudget(fx)
1988
1989 cases := []struct {
1990 name string
1991 version string
1992 label string
1993 notes string
1994 wantErr string // substring the refusal must contain
1995 }{
1996 {"label too long", "lbl0001", strings.Repeat("x", release.MaxLabelLen+1), "", "label: one line"},
1997 {"label multi-line", "lbl0002", "line one\nline two", "", "label: one line"},
1998 // One byte over MaxNotesLen — well inside apiShipMetaMaxBytes, so this
1999 // must reach release.ValidateLabelNotes's own refusal rather than
2000 // tripping the meta part's outer size limit first (the bug the
2001 // apiShipMetaMaxBytes budget exists to avoid: a truncated-JSON
2002 // "malformed meta part" instead of the notes-specific message).
2003 {"notes too big", "lbl0003", "", strings.Repeat("x", release.MaxNotesLen+1), "notes: at most"},
2004 }
2005 for _, c := range cases {
2006 t.Run(c.name, func(t *testing.T) {
2007 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
2008 []shipPart{metaPartFull(t, c.version, "binary", c.label, c.notes), {name: "linux-arm64", body: randomBytes(t, 64)}})
2009 if status != http.StatusBadRequest {
2010 t.Fatalf("%s = %d %v, want 400", c.name, status, out)
2011 }
2012 if !strings.Contains(str(t, out, "error"), c.wantErr) {
2013 t.Fatalf("%s: error = %q, want it to contain %q (proves the real check was reached, not a truncated meta part)",
2014 c.name, str(t, out, "error"), c.wantErr)
2015 }
2016 if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", c.version); err == nil {
2017 t.Fatalf("%s: a manifest was stored despite the refusal", c.name)
2018 }
2019 })
2020 }
2021}
2022
2023// TestAPIShipLargeNotesSucceed: notes comfortably within release.MaxNotesLen
2024// but well past the meta part's OLD flat 8KiB reader limit must still ship —
2025// apiShipMetaMaxBytes exists precisely so a caller inside the documented
2026// ceiling never meets "malformed meta part" for a value the spec calls
2027// valid.
2028func TestAPIShipLargeNotesSucceed(t *testing.T) {
2029 fx := newConsoleFixture(t)
2030 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
2031 t.Fatal(err)
2032 }
2033 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-bignotes")
2034
2035 notes := strings.Repeat("- fixed a thing\n", 640) // ~10KiB, well past the old 8KiB cap
2036 if len(notes) <= 8<<10 || len(notes) > release.MaxNotesLen {
2037 t.Fatalf("test fixture notes = %d bytes, want >8KiB and <= MaxNotesLen(%d)", len(notes), release.MaxNotesLen)
2038 }
2039 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
2040 []shipPart{metaPartFull(t, "big0001", "binary", "", notes), {name: "linux-arm64", body: randomBytes(t, 64)}})
2041 if status != http.StatusCreated {
2042 t.Fatalf("~10KiB notes ship = %d %v, want 201", status, out)
2043 }
2044 m, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "big0001")
2045 if err != nil {
2046 t.Fatalf("manifest: %v", err)
2047 }
2048 if m.Notes != notes {
2049 t.Fatalf("stored notes length = %d, want %d (bytes must survive intact)", len(m.Notes), len(notes))
2050 }
2051}
2052
2053// TestAPIShipExplicitVersionStillWorks: a meta part naming an explicit
2054// version and no label/notes ships exactly as it did before this change —
2055// the mint-or-validate branch must not disturb the existing explicit-version
2056// path.
2057func TestAPIShipExplicitVersionStillWorks(t *testing.T) {
2058 fx := newConsoleFixture(t)
2059 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
2060 t.Fatal(err)
2061 }
2062 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-explicit")
2063
2064 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
2065 []shipPart{metaPart(t, "de2766f8", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}})
2066 if status != http.StatusCreated {
2067 t.Fatalf("explicit-version ship = %d %v, want 201", status, out)
2068 }
2069 if v := str(t, out, "version"); v != "de2766f8" {
2070 t.Fatalf("version = %q, want de2766f8", v)
2071 }
2072 m, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "de2766f8")
2073 if err != nil {
2074 t.Fatalf("manifest: %v", err)
2075 }
2076 if m.Label != "" || m.Notes != "" {
2077 t.Fatalf("manifest label/notes = %q/%q, want both empty", m.Label, m.Notes)
2078 }
2079}
2080
2081// TestAPIShipReshipToleratesADifferentLabel: labels and notes are metadata,
2082// not content — a re-ship of an already-shipped version's exact bytes still
2083// succeeds when the label differs, the same tolerance BuiltAt already gets.
2084// The STORED manifest keeps its ORIGINAL label: the short-circuit answers
2085// success without writing anything (see the comment above
2086// shipArtifactsMatch/shipCapabilitiesMatch in handleAPIShip), so a second
2087// ship can never rewrite a version's label after the fact.
2088func TestAPIShipReshipToleratesADifferentLabel(t *testing.T) {
2089 fx := newConsoleFixture(t)
2090 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
2091 t.Fatal(err)
2092 }
2093 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-reshiplabel")
2094 payload := randomBytes(t, 128)
2095
2096 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
2097 []shipPart{metaPartFull(t, "abc9999", "binary", "label A", ""), {name: "linux-arm64", body: payload}})
2098 if status != http.StatusCreated {
2099 t.Fatalf("first ship = %d %v, want 201", status, out)
2100 }
2101
2102 status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
2103 []shipPart{metaPartFull(t, "abc9999", "binary", "label B", ""), {name: "linux-arm64", body: payload}})
2104 if status != http.StatusOK {
2105 t.Fatalf("re-ship with a different label = %d %v, want 200 (metadata, not content)", status, out)
2106 }
2107
2108 m, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "abc9999")
2109 if err != nil {
2110 t.Fatalf("manifest: %v", err)
2111 }
2112 if m.Label != "label A" {
2113 t.Fatalf("stored label = %q, want the ORIGINAL %q (immutable — a re-ship never rewrites it)", m.Label, "label A")
2114 }
2115}
2116
2117// ---- trash and restore ------------------------------------------------------
2118
2119// trashFixture is a console with titogo claimed in the ops account and a
2120// MEMBER (not owner) token for it: trashing is a member action on purpose —
2121// the 30-day window is what makes the reversible verb cheap.
2122func trashFixture(t *testing.T, code string) (*consoleFixture, string) {
2123 t.Helper()
2124 fx := newConsoleFixture(t)
2125 now := time.Now()
2126 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
2127 t.Fatal(err)
2128 }
2129 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
2130 t.Fatal(err)
2131 }
2132 return fx, cliToken(t, fx, "alice@keymail.dev", code)
2133}
2134
2135// tombstone reads the trash marker straight from the fixture's own bucket —
2136// never through s.cfg.Releases, which some tests below deliberately break.
2137func tombstone(t *testing.T, fx *consoleFixture, app string) *release.AppTombstone {
2138 t.Helper()
2139 tomb, err := release.ReadAppTombstone(context.Background(), fx.rel, release.OpsAccount, app)
2140 if err != nil {
2141 t.Fatalf("read trash marker for %s: %v", app, err)
2142 }
2143 return tomb
2144}
2145
2146// TestAPITrashAndRestoreApp is the whole round trip: a member trashes an app,
2147// the marker lands in the BUCKET (which is where trash state actually
2148// lives), the row follows, every other CLI door stops answering for that
2149// app — and restore puts it all back.
2150func TestAPITrashAndRestoreApp(t *testing.T) {
2151 fx, token := trashFixture(t, "code-trash-roundtrip")
2152
2153 status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "")
2154 if status != http.StatusOK {
2155 t.Fatalf("delete = %d %s, want 200", status, out)
2156 }
2157 var res struct {
2158 Result string `json:"result"`
2159 RestorableUntil string `json:"restorable_until"`
2160 }
2161 if err := json.Unmarshal(out, &res); err != nil {
2162 t.Fatalf("delete body %s: %v", out, err)
2163 }
2164 if res.Result != "trashed" {
2165 t.Fatalf("delete result = %q, want trashed", res.Result)
2166 }
2167 until, err := time.Parse(time.RFC3339, res.RestorableUntil)
2168 if err != nil {
2169 t.Fatalf("restorable_until %q: %v", res.RestorableUntil, err)
2170 }
2171 if want := time.Now().Add(release.TrashCooloff); until.Sub(want) > time.Minute || want.Sub(until) > time.Minute {
2172 t.Fatalf("restorable_until = %s, want ~%s (now + the cooloff)", until, want)
2173 }
2174
2175 tomb := tombstone(t, fx, "titogo")
2176 if tomb == nil {
2177 t.Fatal("no trash marker in the bucket after a 200 delete")
2178 }
2179 if tomb.Actor != "alice@keymail.dev" {
2180 t.Fatalf("marker actor = %q, want the token's own identity", tomb.Actor)
2181 }
2182 if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || has {
2183 t.Fatalf("HasAppIn after trash = %v %v, want false", has, err)
2184 }
2185 if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "titogo"); err != nil || !has {
2186 t.Fatalf("HasTrashedAppIn after trash = %v %v, want true", has, err)
2187 }
2188 // One clock, and it is the MARKER's: the auto-purge pass measures the 30
2189 // days from there, so a row stamped with anything else would give the
2190 // Trash page a "days remaining" nothing honours.
2191 trashed, err := fx.st.TrashedAppsIn(OpsAccountID)
2192 if err != nil || len(trashed) != 1 || trashed[0].App != "titogo" {
2193 t.Fatalf("TrashedAppsIn = %+v %v, want exactly titogo", trashed, err)
2194 }
2195 if trashed[0].DeletedAt.Unix() != tomb.DeletedAt.Unix() {
2196 t.Fatalf("row deleted_at = %s, marker deleted_at = %s — they must be the same clock",
2197 trashed[0].DeletedAt, tomb.DeletedAt)
2198 }
2199
2200 // Every other door resolves through the same live-apps-only lookup, so a
2201 // trashed app is simply not there any more.
2202 if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", token); status != http.StatusNotFound {
2203 t.Fatalf("channels on a trashed app = %d %s, want 404", status, body)
2204 }
2205 if status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", token,
2206 `{"version":"v1","channel":"canary/eu"}`); status != http.StatusNotFound {
2207 t.Fatalf("promote to a trashed app = %d %s, want 404", status, body)
2208 }
2209 if status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, ""); status != http.StatusNotFound {
2210 t.Fatalf("ship to a trashed app = %d %s, want 404", status, body)
2211 }
2212
2213 status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restore", token, "")
2214 if status != http.StatusOK {
2215 t.Fatalf("restore = %d %s, want 200", status, out)
2216 }
2217 var back struct {
2218 Result string `json:"result"`
2219 Note string `json:"note"`
2220 }
2221 if err := json.Unmarshal(out, &back); err != nil {
2222 t.Fatalf("restore body %s: %v", out, err)
2223 }
2224 if back.Result != "restored" {
2225 t.Fatalf("restore result = %q, want restored", back.Result)
2226 }
2227 // Restore resurrects NO routes (spec §3, the anti-ghost rule) — the
2228 // answer has to say so, because nothing else will.
2229 if !strings.Contains(back.Note, "carlos add") {
2230 t.Fatalf("restore note = %q, want it to name `carlos add`", back.Note)
2231 }
2232 if tomb := tombstone(t, fx, "titogo"); tomb != nil {
2233 t.Fatalf("trash marker survived a restore: %+v", tomb)
2234 }
2235 if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || !has {
2236 t.Fatalf("HasAppIn after restore = %v %v, want true", has, err)
2237 }
2238 if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "titogo"); err != nil || has {
2239 t.Fatalf("HasTrashedAppIn after restore = %v %v, want false", has, err)
2240 }
2241 if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", token); status != http.StatusOK {
2242 t.Fatalf("channels after restore = %d %s, want 200", status, body)
2243 }
2244}
2245
2246// TestAPITrashTwiceKeepsTheClock: the second trash must not buy another 30
2247// days. TrashApp is create-only, so the answer has to come from the marker
2248// that is actually there, not from this request's own clock.
2249func TestAPITrashTwiceKeepsTheClock(t *testing.T) {
2250 fx, token := trashFixture(t, "code-trash-twice")
2251
2252 status, first := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "")
2253 if status != http.StatusOK {
2254 t.Fatalf("first delete = %d %s", status, first)
2255 }
2256 // The row is trashed now, so the second call resolves through the trashed
2257 // door… which delete does NOT use: a re-delete of a trashed app is a 404,
2258 // exactly like every other verb. Restore it first, trash it again, and
2259 // assert the marker's original clock is what comes back — the marker is
2260 // deliberately not re-written by a second trash.
2261 if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restore", token, ""); status != http.StatusOK {
2262 t.Fatalf("restore = %d %s", status, out)
2263 }
2264 // Write a marker by hand with an OLD timestamp, then trash through the
2265 // API: the API must report the existing marker's window, not a fresh one.
2266 old := time.Now().Add(-10 * 24 * time.Hour)
2267 if err := release.TrashApp(context.Background(), fx.rel, release.OpsAccount, "titogo", "paul@keymail.dev", old); err != nil {
2268 t.Fatal(err)
2269 }
2270 status, second := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "")
2271 if status != http.StatusOK {
2272 t.Fatalf("second delete = %d %s, want 200 (idempotent)", status, second)
2273 }
2274 var res struct {
2275 RestorableUntil string `json:"restorable_until"`
2276 }
2277 if err := json.Unmarshal(second, &res); err != nil {
2278 t.Fatal(err)
2279 }
2280 until, err := time.Parse(time.RFC3339, res.RestorableUntil)
2281 if err != nil {
2282 t.Fatalf("restorable_until %q: %v", res.RestorableUntil, err)
2283 }
2284 if want := old.Add(release.TrashCooloff); until.Sub(want) > time.Minute || want.Sub(until) > time.Minute {
2285 t.Fatalf("restorable_until = %s, want the EXISTING marker's window ~%s", until, want)
2286 }
2287}
2288
2289// blindMarkerStore writes the trash marker happily and then refuses to read
2290// it back — the shape a half-broken bucket (or a permissions split) takes,
2291// and the one case where this handler has written something it cannot
2292// describe.
2293type blindMarkerStore struct{ release.Store }
2294
2295func (b blindMarkerStore) Get(ctx context.Context, key string) ([]byte, string, error) {
2296 if strings.HasSuffix(key, "/deleted.json") {
2297 return nil, "", errors.New("AccessDenied: s3://carlos-prod-secret/deleted.json request id ABC123")
2298 }
2299 return b.Store.Get(ctx, key)
2300}
2301
2302// TestAPITrashUnconfirmedWindowIsRefused: when the marker cannot be re-read,
2303// the handler must not invent a deadline. Auto-purge destroys at the
2304// MARKER's clock, and the row's own clock never self-heals (materialize
2305// short-circuits on an already-trashed row, TrashAppRow is guarded by
2306// deleted_at = 0) — so a guess here is a wrong "days remaining" forever.
2307// 502, nothing written, and the retry produces the truth.
2308func TestAPITrashUnconfirmedWindowIsRefused(t *testing.T) {
2309 fx, token := trashFixture(t, "code-trash-unconfirmed")
2310 // The app was already trashed headless — the marker exists with its own
2311 // (old) clock while this console's row is still live, so the API call
2312 // below takes TrashApp's idempotent path.
2313 old := time.Now().Add(-9 * 24 * time.Hour)
2314 if err := release.TrashApp(context.Background(), fx.rel, release.OpsAccount, "titogo", "paul@keymail.dev", old); err != nil {
2315 t.Fatal(err)
2316 }
2317 fx.srv.cfg.Releases = blindMarkerStore{fx.rel}
2318
2319 status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "")
2320 if status != http.StatusBadGateway {
2321 t.Fatalf("delete with an unreadable marker = %d %s, want 502", status, body)
2322 }
2323 if !strings.Contains(string(body), "try again") {
2324 t.Fatalf("refusal = %s, want it to invite the retry that produces the true window", body)
2325 }
2326 if strings.Contains(string(body), "AccessDenied") || strings.Contains(string(body), "s3://") {
2327 t.Fatalf("refusal leaks the store's own error text: %s", body)
2328 }
2329 // The row must be untouched: a row stamped now would outlive the marker's
2330 // real deadline and could never be corrected.
2331 if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "titogo"); err != nil || has {
2332 t.Fatalf("HasTrashedAppIn after an unconfirmed trash = %v %v, want false", has, err)
2333 }
2334 if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || !has {
2335 t.Fatalf("HasAppIn after an unconfirmed trash = %v %v, want the row still live", has, err)
2336 }
2337
2338 // The retry, against a bucket that answers: the SAME idempotent path,
2339 // now reporting the marker's original window and stamping the row with it.
2340 fx.srv.cfg.Releases = fx.rel
2341 status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "")
2342 if status != http.StatusOK {
2343 t.Fatalf("retry delete = %d %s, want 200", status, out)
2344 }
2345 var res struct {
2346 RestorableUntil string `json:"restorable_until"`
2347 }
2348 if err := json.Unmarshal(out, &res); err != nil {
2349 t.Fatal(err)
2350 }
2351 until, err := time.Parse(time.RFC3339, res.RestorableUntil)
2352 if err != nil {
2353 t.Fatalf("restorable_until %q: %v", res.RestorableUntil, err)
2354 }
2355 if want := old.Add(release.TrashCooloff); until.Sub(want) > time.Minute || want.Sub(until) > time.Minute {
2356 t.Fatalf("restorable_until = %s, want the ORIGINAL marker's window ~%s", until, want)
2357 }
2358 trashed, err := fx.st.TrashedAppsIn(OpsAccountID)
2359 if err != nil || len(trashed) != 1 {
2360 t.Fatalf("TrashedAppsIn = %+v %v, want exactly titogo", trashed, err)
2361 }
2362 if trashed[0].DeletedAt.Unix() != old.UTC().Unix() {
2363 t.Fatalf("row deleted_at = %s, want the marker's own clock %s", trashed[0].DeletedAt, old.UTC())
2364 }
2365}
2366
2367// TestAPITrashNonMemberIs404: a token holder who is not a member of the
2368// account learns nothing at all — not that the account exists, not that the
2369// app does, and not that either has a trash. Byte-identical to the 404 a
2370// member gets for a name that does not exist.
2371func TestAPITrashNonMemberIs404(t *testing.T) {
2372 fx, member := trashFixture(t, "code-trash-member")
2373 stranger := cliToken(t, fx, "bob@keymail.dev", "code-trash-stranger")
2374
2375 mStatus, mBody := postBody(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/delete", member, "")
2376 sStatus, sBody := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", stranger, "")
2377 if mStatus != http.StatusNotFound || sStatus != http.StatusNotFound {
2378 t.Fatalf("delete: member/unknown-app = %d, stranger/real-app = %d, want 404 both", mStatus, sStatus)
2379 }
2380 if string(mBody) != string(sBody) {
2381 t.Fatalf("delete 404s differ:\n member: %q\n stranger: %q", mBody, sBody)
2382 }
2383 // The restore door answers the same way, through its own resolver.
2384 mStatus, mBody = postBody(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/restore", member, "")
2385 sStatus, sBody = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restore", stranger, "")
2386 if mStatus != http.StatusNotFound || sStatus != http.StatusNotFound {
2387 t.Fatalf("restore: member/unknown-app = %d, stranger/real-app = %d, want 404 both", mStatus, sStatus)
2388 }
2389 if string(mBody) != string(sBody) {
2390 t.Fatalf("restore 404s differ:\n member: %q\n stranger: %q", mBody, sBody)
2391 }
2392 if tomb := tombstone(t, fx, "titogo"); tomb != nil {
2393 t.Fatalf("a stranger's delete wrote a marker: %+v", tomb)
2394 }
2395 if has, _ := fx.st.HasAppIn(OpsAccountID, "titogo"); !has {
2396 t.Fatal("a stranger's delete trashed the row")
2397 }
2398}
2399
2400// TestAPIRestoreLiveAppIs404: restore answers only for apps that are
2401// actually in the trash — a live app is not restorable, and saying so with
2402// anything other than the standard 404 would make this door an oracle for
2403// trash state.
2404func TestAPIRestoreLiveAppIs404(t *testing.T) {
2405 fx, token := trashFixture(t, "code-restore-live")
2406
2407 status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restore", token, "")
2408 if status != http.StatusNotFound {
2409 t.Fatalf("restore of a live app = %d %s, want 404", status, body)
2410 }
2411 if _, unknown := postBody(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/restore", token, ""); string(unknown) != string(body) {
2412 t.Fatalf("restore 404s differ:\n live: %q\n unknown: %q", body, unknown)
2413 }
2414}
2415
2416// TestAPITrashProductionRefused: the production flag IS the deletion
2417// protection (spec §5) — no checkbox, no break-glass. The refusal has to
2418// name the off-switch, because "no" without a way forward is a support
2419// ticket.
2420func TestAPITrashProductionRefused(t *testing.T) {
2421 fx, token := trashFixture(t, "code-trash-production")
2422 if err := release.SetProduction(context.Background(), fx.rel, fx.priv,
2423 release.OpsAccount, "titogo", true, "paul@keymail.dev"); err != nil {
2424 t.Fatal(err)
2425 }
2426
2427 status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "")
2428 if status != http.StatusConflict {
2429 t.Fatalf("delete of a production app = %d %s, want 409", status, body)
2430 }
2431 if !strings.Contains(string(body), "production") {
2432 t.Fatalf("refusal = %s, want it to name the production off-switch", body)
2433 }
2434 if tomb := tombstone(t, fx, "titogo"); tomb != nil {
2435 t.Fatalf("a refused delete wrote a marker: %+v", tomb)
2436 }
2437 if has, _ := fx.st.HasAppIn(OpsAccountID, "titogo"); !has {
2438 t.Fatal("a refused delete trashed the row")
2439 }
2440}
2441
2442// unreadableProductionStore breaks exactly one read — the production flag's
2443// object — which is the state anyone with bucket write but no signing key
2444// can create at will.
2445type unreadableProductionStore struct{ release.Store }
2446
2447func (u unreadableProductionStore) Get(ctx context.Context, key string) ([]byte, string, error) {
2448 if strings.HasSuffix(key, "/production.json") {
2449 return nil, "", errors.New("AccessDenied: s3://carlos-prod-secret/production.json request id ABC123")
2450 }
2451 return u.Store.Get(ctx, key)
2452}
2453
2454// TestAPITrashProductionUnreadableRefused: when the bucket cannot say "not
2455// production", the platform acts as if it had said "production" (spec §5) —
2456// and NOTHING is trashed on the way out.
2457func TestAPITrashProductionUnreadableRefused(t *testing.T) {
2458 fx, token := trashFixture(t, "code-trash-unreadable")
2459 fx.srv.cfg.Releases = unreadableProductionStore{fx.rel}
2460
2461 status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "")
2462 if status != http.StatusConflict {
2463 t.Fatalf("delete with an unreadable flag = %d %s, want 409", status, body)
2464 }
2465 if !strings.Contains(string(body), "production") {
2466 t.Fatalf("refusal = %s, want it to name the production flag", body)
2467 }
2468 if strings.Contains(string(body), "AccessDenied") || strings.Contains(string(body), "s3://") {
2469 t.Fatalf("refusal leaks the store's own error text: %s", body)
2470 }
2471 if tomb := tombstone(t, fx, "titogo"); tomb != nil {
2472 t.Fatalf("a refused delete wrote a marker: %+v", tomb)
2473 }
2474 if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || !has {
2475 t.Fatalf("HasAppIn after a refused delete = %v %v, want the row still live", has, err)
2476 }
2477 if has, _ := fx.st.HasTrashedAppIn(OpsAccountID, "titogo"); has {
2478 t.Fatal("a refused delete trashed the row")
2479 }
2480}
2481
2482// TestAPITrashWithoutAReleaseStore: a console with no bucket cannot trash at
2483// all. A row-only trash would un-hide itself at the next materialize pass —
2484// the bucket is where trash state lives.
2485func TestAPITrashWithoutAReleaseStore(t *testing.T) {
2486 fx, token := trashFixture(t, "code-trash-nobucket")
2487 fx.srv.cfg.Releases = nil
2488
2489 status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "")
2490 if status != http.StatusServiceUnavailable {
2491 t.Fatalf("delete with no release store = %d %s, want 503", status, body)
2492 }
2493 if has, _ := fx.st.HasAppIn(OpsAccountID, "titogo"); !has {
2494 t.Fatal("a 503 delete trashed the row anyway")
2495 }
2496}
2497
2498// TestAPITrashNoToken: both doors answer an anonymous caller with cliUser's
2499// one 401 — before any name in the path is resolved.
2500func TestAPITrashNoToken(t *testing.T) {
2501 fx, _ := trashFixture(t, "code-trash-anon")
2502
2503 for _, verb := range []string{"delete", "restore"} {
2504 status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/"+verb, "", "")
2505 if status != http.StatusUnauthorized {
2506 t.Fatalf("%s with no token = %d %s, want 401", verb, status, body)
2507 }
2508 if !strings.Contains(string(body), "carlos auth login") {
2509 t.Fatalf("%s with no token = %s, want cliUser's own wording", verb, body)
2510 }
2511 }
2512 if tomb := tombstone(t, fx, "titogo"); tomb != nil {
2513 t.Fatalf("an anonymous delete wrote a marker: %+v", tomb)
2514 }
2515}
2516
2517// TestAPICreateAppHappy: `carlos apps create` claims a name through the same
2518// claimApp the browser form uses — the row exists afterwards (so every other
2519// API route's HasAppIn gate opens), the audit row carries the parent spec's
2520// "(cli)" suffix, and the name is normalized exactly as the browser door
2521// normalizes it.
2522func TestAPICreateAppHappy(t *testing.T) {
2523 fx := newConsoleFixture(t)
2524 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil {
2525 t.Fatal(err)
2526 }
2527 token := cliToken(t, fx, "alice@keymail.dev", "code-create-alice")
2528
2529 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": " Paul "}, token)
2530 if status != http.StatusOK {
2531 t.Fatalf("create = %d %v", status, out)
2532 }
2533 if str(t, out, "account") != "bab" || str(t, out, "app") != "paul" {
2534 t.Fatalf("create body = %v", out)
2535 }
2536 if got, want := str(t, out, "url"), fx.ts.URL+"/a/bab/apps/paul"; got != want {
2537 t.Fatalf("url = %q, want %q", got, want)
2538 }
2539 if has, err := fx.st.HasAppIn(OpsAccountID, "paul"); err != nil || !has {
2540 t.Fatalf("HasAppIn(paul) = %v %v, want true", has, err)
2541 }
2542 events, err := fx.st.Events(OpsAccountID, "paul", 10)
2543 if err != nil || len(events) != 1 {
2544 t.Fatalf("events = %v %v, want exactly one", events, err)
2545 }
2546 if e := events[0]; e.Action != "create" || e.Address != "alice@keymail.dev" ||
2547 e.Detail != "created app paul (cli)" {
2548 t.Fatalf("audit row = %+v", e)
2549 }
2550 // A second claim of the same name is the ordinary duplicate refusal.
2551 status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "paul"}, token)
2552 if status != http.StatusConflict {
2553 t.Fatalf("duplicate create = %d %v, want 409", status, out)
2554 }
2555}
2556
2557// TestAPICreateAppWithPlace: `carlos apps create -place fleet/<f>` (the
2558// 2026-08-06 ruling) stores the placement on the app record at create time
2559// — the store-of-record appPlacement now consults FIRST, which is what
2560// makes this work for an app with zero routes (see
2561// TestAPIInstanceCreateInheritsRouteLessPlacedApp, apiinstances_test.go).
2562func TestAPICreateAppWithPlace(t *testing.T) {
2563 fx := newConsoleFixture(t)
2564 token := cliToken(t, fx, "paul@keymail.dev", "code-create-place")
2565
2566 if status, body := postJSON(t, fx.ts.URL+"/api/cli/fleets/bab/new",
2567 map[string]string{"name": "pi", "bucket": "pi-fleet-data"}, token); status != http.StatusOK {
2568 t.Fatalf("create fleet = %d %v, want 200", status, body)
2569 }
2570 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new",
2571 map[string]string{"name": "titogo", "place": "fleet/pi"}, token)
2572 if status != http.StatusOK {
2573 t.Fatalf("create with place = %d %v, want 200", status, out)
2574 }
2575 // The response ECHOES what actually stuck (the 2026-08-06 review's
2576 // observability fix) — the CLI (runAppsCreate) compares this field
2577 // against what it asked for, so it must carry the real value, not just
2578 // a boolean.
2579 if got := str(t, out, "placement"); got != "fleet/pi" {
2580 t.Fatalf("response placement = %q, want the echoed fleet/pi", got)
2581 }
2582 p, err := fx.st.AppPlacement(OpsAccountID, "titogo")
2583 if err != nil || p != "fleet/pi" {
2584 t.Fatalf("AppPlacement after create-with-place = %q %v, want fleet/pi", p, err)
2585 }
2586
2587 // Clearing side: a create with NO place at all still leaves the app
2588 // unplaced, exactly as before this field existed — and the echo agrees.
2589 status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "unplaced"}, token)
2590 if status != http.StatusOK {
2591 t.Fatalf("plain create = %d %v, want 200", status, out)
2592 }
2593 if got := str(t, out, "placement"); got != "" {
2594 t.Fatalf("response placement = %q, want \"\" for a plain create", got)
2595 }
2596 if p, err := fx.st.AppPlacement(OpsAccountID, "unplaced"); err != nil || p != "" {
2597 t.Fatalf("AppPlacement of a plain create = %q %v, want \"\"", p, err)
2598 }
2599}
2600
2601// TestClaimAppPlacementWriteFailureDoesNotUndoClaim exercises claimApp's
2602// "the claim succeeded but the placement write did not" branch directly —
2603// the observability fix a 2026-08-06 review required after finding the
2604// create path printed a guarantee ("placed on fleet/pi") it never verified.
2605// Server.store is a concrete *Store (no interface seam to swap in a
2606// failing double), so the placement column itself is dropped after the
2607// fixture's normal migration has already created it — CreateAppIn (which
2608// never touches that column) keeps working, and SetAppPlacement now fails
2609// on every call, which is exactly the scenario claimApp's `placed` return
2610// (and handleAPICreateApp's echo, tested at the HTTP layer above) exist to
2611// make observable instead of silently trusted.
2612func TestClaimAppPlacementWriteFailureDoesNotUndoClaim(t *testing.T) {
2613 fx := newConsoleFixture(t)
2614 if _, err := fx.st.db.Exec(`ALTER TABLE apps DROP COLUMN placement`); err != nil {
2615 t.Fatalf("drop placement column (test setup): %v", err)
2616 }
2617
2618 acct := &Account{ID: OpsAccountID, Sqid: fx.sqid}
2619 outcome, placed, _ := fx.srv.claimApp(context.Background(), acct, "titogo", "paul@keymail.dev", "fleet/pi", false, regionReq{})
2620 if outcome != claimOK {
2621 t.Fatalf("claimApp outcome = %v, want claimOK — the claim itself must still succeed", outcome)
2622 }
2623 if placed != "" {
2624 t.Fatalf("claimApp placed = %q, want \"\" — the placement write failed and must not be reported as having stuck", placed)
2625 }
2626 if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || !has {
2627 t.Fatalf("HasAppIn after a failed placement write = %v %v, want true — the claim must not be undone", has, err)
2628 }
2629}
2630
2631// TestAPICreateAppWithInvalidPlaceShape400: a place value that doesn't parse
2632// as "fleet/<name>" is a plain 400 — the same grammar check `carlos apps
2633// place` applies to its target — and, critically, the app is NOT created:
2634// create-with-place must not be weaker than place, and it must not be
2635// stronger in the other direction either (a bad place silently ignored,
2636// leaving an app the caller believes is placed).
2637func TestAPICreateAppWithInvalidPlaceShape400(t *testing.T) {
2638 fx := newConsoleFixture(t)
2639 token := cliToken(t, fx, "paul@keymail.dev", "code-create-place-badshape")
2640
2641 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new",
2642 map[string]string{"name": "titogo", "place": "eu"}, token)
2643 if status != http.StatusBadRequest {
2644 t.Fatalf("create with a bare region code place = %d %v, want 400", status, out)
2645 }
2646 if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || has {
2647 t.Fatalf("HasAppIn after a refused place = %v %v, want false — nothing should have been created", has, err)
2648 }
2649}
2650
2651// TestAPICreateAppWithForeignPlace404: a place value naming a fleet owned by
2652// a DIFFERENT account is the same byte-identical 404 apiFleet and
2653// handleAPIPlace's own target check give everywhere else — and, again, the
2654// app is not created. Creating with a foreign place must refuse exactly as
2655// hard as `carlos apps place` onto the same foreign fleet does.
2656func TestAPICreateAppWithForeignPlace404(t *testing.T) {
2657 fx := newConsoleFixture(t)
2658 now := time.Now()
2659 token := cliToken(t, fx, "paul@keymail.dev", "code-create-place-foreign")
2660
2661 if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil {
2662 t.Fatal(err)
2663 }
2664 bobToken := cliToken(t, fx, "bob@keymail.dev", "code-create-place-foreign-bob")
2665 if status, body := postJSON(t, fx.ts.URL+"/api/cli/fleets/cdef/new",
2666 map[string]string{"name": "acme", "bucket": "bob-data"}, bobToken); status != http.StatusOK {
2667 t.Fatalf("bob's fleet create = %d %v, want 200", status, body)
2668 }
2669
2670 want := canonicalNotFound(t)
2671 status, h, out := rawReq(t, http.MethodPost, fx.ts.URL+"/api/cli/apps/bab/new", token,
2672 `{"name":"titogo","place":"fleet/acme"}`)
2673 assertNotFound(t, "create with a foreign-owned place target", want, status, h, out)
2674 if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || has {
2675 t.Fatalf("HasAppIn after a refused foreign place = %v %v, want false", has, err)
2676 }
2677}
2678
2679// TestAPICreateAppRefusalMatrix: the doors that must NOT open, and the shapes
2680// of their refusals. The one that matters most is byte-identical 404s — an
2681// account that doesn't exist and an account the token's holder doesn't belong
2682// to must be indistinguishable, exactly as they are on every other API route.
2683func TestAPICreateAppRefusalMatrix(t *testing.T) {
2684 fx := newConsoleFixture(t)
2685 now := time.Now()
2686 // bac: somebody else's account.
2687 if err := fx.st.CreateAccount(2, "bac", "widgets", "bob@keymail.dev", now); err != nil {
2688 t.Fatal(err)
2689 }
2690 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
2691 t.Fatal(err)
2692 }
2693 token := cliToken(t, fx, "alice@keymail.dev", "code-create-refusals")
2694
2695 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "x"}, ""); status != http.StatusUnauthorized {
2696 t.Fatalf("no token = %d %v, want 401", status, out)
2697 }
2698 stTheirs, bodyTheirs := postRaw(t, fx.ts.URL+"/api/cli/apps/bac/new", map[string]string{"name": "fresh"}, token)
2699 stUnknown, bodyUnknown := postRaw(t, fx.ts.URL+"/api/cli/apps/zzz/new", map[string]string{"name": "fresh"}, token)
2700 if stTheirs != http.StatusNotFound || stUnknown != http.StatusNotFound {
2701 t.Fatalf("someone-else's = %d, unknown = %d, want 404 and 404", stTheirs, stUnknown)
2702 }
2703 if bodyTheirs != bodyUnknown {
2704 t.Fatalf("404 bodies differ:\n%q\n%q", bodyTheirs, bodyUnknown)
2705 }
2706 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "9lives"}, token); status != http.StatusBadRequest {
2707 t.Fatalf("invalid name = %d %v, want 400", status, out)
2708 }
2709 // "new" is the literal name the console's own routes shadow — claimApp's
2710 // claimReserved leg, refused before any store read/write.
2711 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "new"}, token); status != http.StatusConflict {
2712 t.Fatalf("reserved name = %d %v, want 409", status, out)
2713 } else if !strings.Contains(str(t, out, "error"), "reserved") {
2714 t.Fatalf("reserved name error = %v, want it to say reserved", out)
2715 }
2716 // kass is the fixture's already-claimed app.
2717 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "kass"}, token); status != http.StatusConflict {
2718 t.Fatalf("claimed name = %d %v, want 409", status, out)
2719 }
2720 // A CLI-granted app stays claim-proof in the ops account.
2721 if err := fx.st.Grant("bob@keymail.dev", "granted"); err != nil {
2722 t.Fatal(err)
2723 }
2724 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "granted"}, token); status != http.StatusConflict {
2725 t.Fatalf("granted name = %d %v, want 409", status, out)
2726 }
2727 // So does a name whose objects already sit in the release store.
2728 if _, err := fx.rel.Put(context.Background(),
2729 release.AppPrefix("bab", "shipped")+"releases/0.0.1/manifest.json", []byte("{}"), ""); err != nil {
2730 t.Fatal(err)
2731 }
2732 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "shipped"}, token); status != http.StatusConflict {
2733 t.Fatalf("bucket-held name = %d %v, want 409", status, out)
2734 }
2735 // An ambiguous account NAME is refused, not guessed at — the same 409
2736 // every other route gives, via the shared apiAccount.
2737 if err := fx.st.CreateAccount(3, "bad", "ops", "alice@keymail.dev", now); err != nil {
2738 t.Fatal(err)
2739 }
2740 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/ops/new", map[string]string{"name": "fresh"}, token)
2741 if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), "ambiguous") {
2742 t.Fatalf("ambiguous account = %d %v, want 409 naming the ambiguity", status, out)
2743 }
2744}
2745
2746// TestAPICreateAppRateLimit: the create budget is its own (not `write`'s),
2747// keyed by address, and spent per ATTEMPT — refused probes count, or the
2748// limiter would bound nothing an attacker cares about.
2749func TestAPICreateAppRateLimit(t *testing.T) {
2750 fx := newConsoleFixture(t)
2751 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil {
2752 t.Fatal(err)
2753 }
2754 token := cliToken(t, fx, "alice@keymail.dev", "code-create-limit")
2755
2756 for i := 0; i < 3; i++ { // refused probes spend budget too
2757 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "9bad"}, token); status != http.StatusBadRequest {
2758 t.Fatalf("probe %d = %d %v, want 400", i, status, out)
2759 }
2760 }
2761 for i := 0; i < cliCreateBurst-3; i++ {
2762 name := fmt.Sprintf("app-%d", i)
2763 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": name}, token); status != http.StatusOK {
2764 t.Fatalf("create %s = %d %v", name, status, out)
2765 }
2766 }
2767 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "one-too-many"}, token)
2768 if status != http.StatusTooManyRequests {
2769 t.Fatalf("over budget = %d %v, want 429", status, out)
2770 }
2771 if has, err := fx.st.HasAppIn(OpsAccountID, "one-too-many"); err != nil || has {
2772 t.Fatalf("over-budget name claimed anyway: %v %v", has, err)
2773 }
2774 // The budget belongs to the ADDRESS, not the token: a second token for
2775 // the same address inherits the spent budget — re-minting tokens must
2776 // not be a reset button.
2777 token2 := cliToken(t, fx, "alice@keymail.dev", "code-create-limit-2")
2778 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "second-token"}, token2); status != http.StatusTooManyRequests {
2779 t.Fatalf("second token, same address = %d %v, want 429", status, out)
2780 }
2781 // ...while a different address's budget is its own.
2782 if err := fx.st.AddMember(OpsAccountID, "bob@keymail.dev", RoleMember, time.Now()); err != nil {
2783 t.Fatal(err)
2784 }
2785 bobToken := cliToken(t, fx, "bob@keymail.dev", "code-create-limit-bob")
2786 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "bob-fresh"}, bobToken); status != http.StatusOK {
2787 t.Fatalf("fresh address = %d %v, want 200", status, out)
2788 }
2789 // The browser door is NOT behind this budget: the same address, its
2790 // budget spent, still claims through the console form.
2791 browser := newBrowser(t)
2792 fx.setUser("alice@keymail.dev")
2793 signUp(t, fx, browser, "code-create-limit-browser")
2794 postForm(t, browser, fx.u("/apps/new"),
2795 url.Values{"name": {"via-browser"}, "csrf": {sessionToken(t, fx, browser)}})
2796 if has, err := fx.st.HasAppIn(OpsAccountID, "via-browser"); err != nil || !has {
2797 t.Fatalf("browser claim after CLI budget spent = %v %v, want true", has, err)
2798 }
2799}
2800
2801// TestAPIShipEchoesTheBuildProbe: `carlos deploy` learns the app's build
2802// probe from the ship it was already making — no second door, no second
2803// round trip — so the watch can poll the app's own build report instead of
2804// the edge's route-row header. It has to ride BOTH ship answers: a repeated
2805// deploy of bytes already shipped short-circuits to 200, and that deploy
2806// still has a watch to run.
2807func TestAPIShipEchoesTheBuildProbe(t *testing.T) {
2808 fx := newConsoleFixture(t)
2809 now := time.Now()
2810 if _, err := fx.st.CreateAppIn(OpsAccountID, "console", "paul@keymail.dev", now); err != nil {
2811 t.Fatal(err)
2812 }
2813 if err := fx.st.SetAppBuildProbe(OpsAccountID, "console", "/healthz"); err != nil {
2814 t.Fatal(err)
2815 }
2816 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-probe")
2817 payload := randomBytes(t, 256)
2818 parts := []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}}
2819
2820 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token, parts)
2821 if status != http.StatusCreated {
2822 t.Fatalf("ship = %d %v", status, out)
2823 }
2824 if out["build_probe"] != "/healthz" {
2825 t.Fatalf("ship body build_probe = %v, want /healthz — the deploy watch has no other way to learn it", out["build_probe"])
2826 }
2827
2828 status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token,
2829 []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}})
2830 if status != http.StatusOK {
2831 t.Fatalf("re-ship of identical bytes = %d %v, want 200", status, out)
2832 }
2833 if out["build_probe"] != "/healthz" {
2834 t.Fatalf("no-op ship body build_probe = %v, want /healthz (that deploy still watches)", out["build_probe"])
2835 }
2836}
2837
2838// TestAPIShipOmitsTheBuildProbeWhenNoneIsDeclared: every ship the fleet does
2839// today answers byte for byte as it did — the key is absent, and a CLI
2840// decoding it gets "" and keeps the header watch.
2841func TestAPIShipOmitsTheBuildProbeWhenNoneIsDeclared(t *testing.T) {
2842 fx := newConsoleFixture(t)
2843 now := time.Now()
2844 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
2845 t.Fatal(err)
2846 }
2847 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-noprobe")
2848 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
2849 []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: randomBytes(t, 256)}})
2850 if status != http.StatusCreated {
2851 t.Fatalf("ship = %d %v", status, out)
2852 }
2853 if _, present := out["build_probe"]; present {
2854 t.Fatalf("ship body carries build_probe for an app that declares none: %v", out)
2855 }
2856}
2857
2858// TestAPIShipRefusesAnUnreadableAppRow pins the ruling the ship door's ONE
2859// app-row read follows: it fails CLOSED.
2860//
2861// This test used to assert the opposite (TestAPIShipSurvivesAnUnreadableBuildProbe,
2862// task 6): the build-probe echo read the row AFTER release.Ship had stored the
2863// bytes, and there the only honest answer was to degrade — refusing a
2864// successful upload over a metadata read would have been a lie about what
2865// happened. That premise is gone. The control-plane checks need the same row
2866// BEFORE a byte is written, so the read moved up and the two columns are read
2867// once, together; and an unreadable row is now a question the guard cannot
2868// answer, on the one app where the wrong answer ships an amd64 binary to a
2869// Graviton box. Refusing costs the caller a retry and leaves nothing
2870// half-done.
2871//
2872// The column is dropped rather than the database closed: closing it would
2873// fail the token lookup and the app 404 long before the ship handler reached
2874// this read. Dropping ONE column leaves every earlier gate working and fails
2875// exactly AppFlags.
2876func TestAPIShipRefusesAnUnreadableAppRow(t *testing.T) {
2877 fx := newConsoleFixture(t)
2878 now := time.Now()
2879 if _, err := fx.st.CreateAppIn(OpsAccountID, "console", "paul@keymail.dev", now); err != nil {
2880 t.Fatal(err)
2881 }
2882 if err := fx.st.SetAppBuildProbe(OpsAccountID, "console", "/healthz"); err != nil {
2883 t.Fatal(err)
2884 }
2885 token := cliToken(t, fx, "paul@keymail.dev", "code-ship-probe-blip")
2886 if _, err := fx.st.db.Exec(`ALTER TABLE apps DROP COLUMN build_probe`); err != nil {
2887 t.Fatalf("drop the probe column: %v", err)
2888 }
2889
2890 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token,
2891 []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: randomBytes(t, 256)}})
2892 if status != http.StatusServiceUnavailable {
2893 t.Fatalf("ship with an unreadable app row = %d %v, want 503 — the flag check cannot fall through", status, out)
2894 }
2895 if msg := str(t, out, "error"); !strings.Contains(msg, "nothing was shipped") {
2896 t.Fatalf("refusal %q does not tell the caller the upload did not land", msg)
2897 }
2898 // And it really did not land: a refusal that stores the release anyway is
2899 // the worst of both answers.
2900 if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "console", "abc1234"); err == nil {
2901 t.Fatal("the release was stored despite the 503")
2902 }
2903}
2904
2905// TestAPIPromoteRetryResignsThePointer: `carlos promote --retry` over the API
2906// door re-signs a channel's current promotion with a fresh PromotedAt, so a
2907// box that refused that promotion tries it again. The version never moves.
2908func TestAPIPromoteRetryResignsThePointer(t *testing.T) {
2909 fx := newConsoleFixture(t)
2910 now := time.Now()
2911 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
2912 t.Fatal(err)
2913 }
2914 shipVersion(t, fx, "titogo", "abc1234")
2915 token := cliToken(t, fx, "paul@keymail.dev", "code-promote-retry")
2916
2917 url := fx.ts.URL + "/api/cli/apps/bab/titogo/promote"
2918 if status, out := postJSON(t, url, map[string]string{"version": "abc1234", "channel": "canary/x"}, token); status != http.StatusOK {
2919 t.Fatalf("first promote = %d %v", status, out)
2920 }
2921 before := fx.channelVersion(t, "titogo", "canary/x")
2922 time.Sleep(1100 * time.Millisecond) // PromotedAt is truncated to the second
2923
2924 status, out := postJSON(t, url, map[string]any{"version": "abc1234", "channel": "canary/x", "retry": true}, token)
2925 if status != http.StatusOK {
2926 t.Fatalf("retry = %d %v, want 200", status, out)
2927 }
2928 after := fx.channelVersion(t, "titogo", "canary/x")
2929 if after.Version != "abc1234" || !after.PromotedAt.After(before.PromotedAt) {
2930 t.Fatalf("pointer after retry = %+v, want abc1234 with a later PromotedAt than %v", after, before.PromotedAt)
2931 }
2932}
2933
2934// Ledger Task 7: --retry buys nothing past a deploy credential's scope. A
2935// retry onto a channel the credential cannot promote to gets exactly the
2936// plain promote's refusal, and the pointer is not re-signed.
2937func TestAPIPromoteRetryMeetsTheDeployScope(t *testing.T) {
2938 fx, _, deploy := deployFixture(t)
2939 seedChannel(t, fx, "hello", "beta", "v1")
2940 before := fx.channelVersion(t, "hello", "beta")
2941 url := fx.ts.URL + "/api/cli/apps/bab/hello/promote"
2942 plainStatus, plain := postJSON(t, url, map[string]any{"version": "v1", "channel": "beta"}, deploy)
2943 retryStatus, retry := postJSON(t, url, map[string]any{"version": "v1", "channel": "beta", "retry": true}, deploy)
2944 if plainStatus != http.StatusForbidden || retryStatus != plainStatus {
2945 t.Fatalf("plain = %d %v, retry = %d %v; want the same 403", plainStatus, plain, retryStatus, retry)
2946 }
2947 if plain["error"] != retry["error"] || !strings.Contains(str(t, retry, "error"), "beta is not one of them") {
2948 t.Fatalf("retry refusal %q, plain %q: want the identical D22 text", retry["error"], plain["error"])
2949 }
2950 if after := fx.channelVersion(t, "hello", "beta"); !after.PromotedAt.Equal(before.PromotedAt) {
2951 t.Fatalf("beta re-signed at %v (was %v) by an out-of-scope retry", after.PromotedAt, before.PromotedAt)
2952 }
2953}
2954