carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Tests for API mode resolution and the console API client the other CLI
2// verbs (Task 5) build on: which mode the CLI is in (direct bucket access or
3// through a console it holds a credential for), and the four calls a session
4// in API mode can make.
5package main
6
7import (
8 "bytes"
9 "context"
10 "encoding/base64"
11 "encoding/json"
12 "errors"
13 "flag"
14 "io"
15 "net/http"
16 "net/http/httptest"
17 "net/url"
18 "os"
19 "path/filepath"
20 "strings"
21 "sync"
22 "sync/atomic"
23 "testing"
24 "time"
25
26 "github.com/carlosframework/platform/internal/appconfig"
27 "github.com/carlosframework/platform/internal/caps"
28 "github.com/carlosframework/platform/internal/edge"
29 "github.com/carlosframework/platform/internal/ledger"
30 "github.com/carlosframework/platform/internal/release"
31)
32
33// captureStdout swaps the package-level stdout var (cli.go — the seam the
34// four API-aware verbs print through) for a buffer, restoring it on cleanup.
35// log.Fatal paths are untouched by this seam on purpose — those still exit
36// the process, exactly as before.
37func captureStdout(t *testing.T) *bytes.Buffer {
38 t.Helper()
39 var buf bytes.Buffer
40 orig := stdout
41 stdout = &buf
42 t.Cleanup(func() { stdout = orig })
43 return &buf
44}
45
46// clearDeploymentEnv ensures none of the three env vars resolveMode/pick
47// read leak in from the process running the test binary — resolveMode must
48// see a clean slate to make its own env check meaningful, and CARLOS_CONSOLE
49// left set would silently change which pick() branch a "" -console flag
50// takes.
51func clearDeploymentEnv(t *testing.T) {
52 t.Helper()
53 t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
54 t.Setenv("CARLOS_DEPLOYMENT_DIR", "")
55 t.Setenv("CARLOS_CONSOLE", "")
56 t.Setenv("CARLOS_SERVICE_TOKEN", "")
57}
58
59func TestResolveModeDirectWins(t *testing.T) {
60 useTempCredentials(t)
61 clearDeploymentEnv(t)
62 t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "some-bucket")
63
64 // A credential is present too — direct mode must win regardless, since a
65 // box can be both logged in and configured for direct bucket access, and
66 // existing direct-mode behaviour must not change out from under it.
67 f, _ := loadCredentials()
68 f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "t1", ExpiresAt: time.Now().Add(time.Hour)})
69 if err := f.save(); err != nil {
70 t.Fatal(err)
71 }
72
73 sess, err := resolveMode("")
74 if err != nil || sess != nil {
75 t.Fatalf("resolveMode with CARLOS_DEPLOYMENT_BUCKET set = (%+v, %v), want (nil, nil)", sess, err)
76 }
77}
78
79func TestResolveModeDirWins(t *testing.T) {
80 useTempCredentials(t)
81 clearDeploymentEnv(t)
82 t.Setenv("CARLOS_DEPLOYMENT_DIR", t.TempDir())
83
84 sess, err := resolveMode("")
85 if err != nil || sess != nil {
86 t.Fatalf("resolveMode with CARLOS_DEPLOYMENT_DIR set = (%+v, %v), want (nil, nil)", sess, err)
87 }
88}
89
90func TestResolveModeAPI(t *testing.T) {
91 useIsolatedConfig(t)
92 clearDeploymentEnv(t)
93
94 f, _ := loadCredentials()
95 f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "t1", ExpiresAt: time.Now().Add(time.Hour)})
96 if err := f.save(); err != nil {
97 t.Fatal(err)
98 }
99
100 sess, err := resolveMode("")
101 if err != nil {
102 t.Fatalf("resolveMode: %v", err)
103 }
104 if sess == nil {
105 t.Fatal("resolveMode with one credential = nil session, want one")
106 }
107 if sess.console != "https://a.example" {
108 t.Fatalf("session console = %q, want https://a.example", sess.console)
109 }
110 if sess.address != "a@keymail.dev" {
111 t.Fatalf("session address = %q, want a@keymail.dev", sess.address)
112 }
113 if sess.c == nil {
114 t.Fatal("session has no console client")
115 }
116}
117
118func TestResolveModeNothing(t *testing.T) {
119 useIsolatedConfig(t)
120 clearDeploymentEnv(t)
121 forceTerminal(t, false) // pin: this asserts the NON-interactive refusal
122
123 sess, err := resolveMode("")
124 if sess != nil {
125 t.Fatalf("resolveMode with nothing configured = %+v, want nil session", sess)
126 }
127 if err == nil {
128 t.Fatal("resolveMode with nothing configured must error")
129 }
130 if !strings.Contains(err.Error(), "CARLOS_DEPLOYMENT_BUCKET") {
131 t.Fatalf("error %q does not mention CARLOS_DEPLOYMENT_BUCKET", err)
132 }
133 if !strings.Contains(err.Error(), "carlos auth login") {
134 t.Fatalf("error %q does not mention carlos auth login", err)
135 }
136}
137
138// TestResolveModeExpiredCredentialOnly pins the minor fix: a credential that
139// EXISTS but has expired is a different situation from nothing configured at
140// all, and deserves a message that says so instead of offering to set up
141// direct mode from scratch.
142func TestResolveModeExpiredCredentialOnly(t *testing.T) {
143 useIsolatedConfig(t)
144 clearDeploymentEnv(t)
145 forceTerminal(t, false) // pin: this asserts the NON-interactive refusal
146
147 f, _ := loadCredentials()
148 f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "t1", ExpiresAt: time.Now().Add(-time.Hour)})
149 if err := f.save(); err != nil {
150 t.Fatal(err)
151 }
152
153 sess, err := resolveMode("")
154 if sess != nil {
155 t.Fatalf("resolveMode with only an expired credential = %+v, want nil session", sess)
156 }
157 if err == nil {
158 t.Fatal("resolveMode with only an expired credential must error")
159 }
160 if !strings.Contains(err.Error(), "https://a.example") {
161 t.Fatalf("error %q does not name the expired console", err)
162 }
163 if !strings.Contains(err.Error(), "expired") {
164 t.Fatalf("error %q does not say the credential expired", err)
165 }
166 if !strings.Contains(err.Error(), "carlos auth login") {
167 t.Fatalf("error %q does not name the fix", err)
168 }
169 if strings.Contains(err.Error(), "CARLOS_DEPLOYMENT_BUCKET") {
170 t.Fatalf("error %q wrongly offers direct mode for an expired (not absent) credential", err)
171 }
172}
173
174// TestResolveModeConsoleFlagRefusedWithStoreEnv pins the design ruling from
175// the review round (2026-08-03): an explicit -console alongside a store env
176// is refused outright, rather than silently winning as direct mode with
177// -console quietly ignored — the spec's "the two modes never mix in one
178// invocation" intent (§1) would otherwise be violated invisibly. Both store
179// env vars are checked; a BARE env with no -console still wins silently,
180// unchanged (already pinned by TestResolveModeDirectWins/TestResolveModeDirWins,
181// both of which call resolveMode("")).
182func TestResolveModeConsoleFlagRefusedWithStoreEnv(t *testing.T) {
183 for _, tc := range []struct {
184 name string
185 envVar string
186 }{
187 {"bucket", "CARLOS_DEPLOYMENT_BUCKET"},
188 {"dir", "CARLOS_DEPLOYMENT_DIR"},
189 } {
190 t.Run(tc.name, func(t *testing.T) {
191 useTempCredentials(t)
192 clearDeploymentEnv(t)
193 t.Setenv(tc.envVar, "something")
194
195 sess, err := resolveMode("https://a.example")
196 if sess != nil {
197 t.Fatalf("resolveMode with --console AND %s set = %+v, want nil session", tc.envVar, sess)
198 }
199 if err == nil {
200 t.Fatalf("resolveMode with --console AND %s set must error", tc.envVar)
201 }
202 if !strings.Contains(err.Error(), "-console") {
203 t.Fatalf("error %q does not mention --console", err)
204 }
205 if !strings.Contains(err.Error(), tc.envVar) {
206 t.Fatalf("error %q does not mention %s", err, tc.envVar)
207 }
208 })
209 }
210}
211
212func newTestSession(base string) *apiSession {
213 return &apiSession{c: newConsoleClient(base, "cli-token"), console: base, address: "a@keymail.dev"}
214}
215
216// TestAPISessionShipMultipart pins the wire shape handleAPIShip
217// (internal/console/apicli.go) requires: bearer auth, the meta part first
218// with exact JSON, then artifact parts in map-key-sorted order (so the wire
219// bytes are deterministic instead of depending on map iteration order).
220func TestAPISessionShipMultipart(t *testing.T) {
221 var gotAuth string
222 var partNames []string
223 var metaBody string
224 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
225 gotAuth = r.Header.Get("Authorization")
226 mr, err := r.MultipartReader()
227 if err != nil {
228 t.Errorf("request was not multipart: %v", err)
229 w.WriteHeader(http.StatusInternalServerError)
230 return
231 }
232 for {
233 part, err := mr.NextPart()
234 if err == io.EOF {
235 break
236 }
237 if err != nil {
238 t.Errorf("reading part: %v", err)
239 w.WriteHeader(http.StatusInternalServerError)
240 return
241 }
242 b, err := io.ReadAll(part)
243 if err != nil {
244 t.Errorf("reading part body: %v", err)
245 w.WriteHeader(http.StatusInternalServerError)
246 return
247 }
248 if part.FormName() == "meta" {
249 metaBody = string(b)
250 }
251 partNames = append(partNames, part.FormName())
252 }
253 w.WriteHeader(http.StatusCreated)
254 json.NewEncoder(w).Encode(map[string]any{
255 "result": "shipped", "version": "1.2.3", "artifacts": map[string]any{},
256 })
257 }))
258 defer ts.Close()
259
260 dir := t.TempDir()
261 fileA := filepath.Join(dir, "a.bin")
262 fileB := filepath.Join(dir, "b.bin")
263 if err := os.WriteFile(fileA, []byte("aaa"), 0o600); err != nil {
264 t.Fatal(err)
265 }
266 if err := os.WriteFile(fileB, []byte("bbb"), 0o600); err != nil {
267 t.Fatal(err)
268 }
269
270 s := newTestSession(ts.URL)
271 res, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", map[string]string{
272 "zeta-amd64": fileB,
273 "alpha-amd64": fileA,
274 }, nil, false)
275 if err != nil {
276 t.Fatalf("ship: %v", err)
277 }
278 if res.NoOp {
279 t.Fatal("a fresh (201) ship must not report NoOp")
280 }
281 if gotAuth != "Bearer cli-token" {
282 t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer cli-token")
283 }
284 if want := `{"version":"1.2.3","kind":"binary"}`; metaBody != want {
285 t.Fatalf("meta part = %q, want %q", metaBody, want)
286 }
287 wantOrder := []string{"meta", "alpha-amd64", "zeta-amd64"}
288 if len(partNames) != len(wantOrder) {
289 t.Fatalf("part names = %v, want %v", partNames, wantOrder)
290 }
291 for i := range wantOrder {
292 if partNames[i] != wantOrder[i] {
293 t.Fatalf("part order = %v, want %v", partNames, wantOrder)
294 }
295 }
296}
297
298// TestAPISessionShipNoOp pins handleAPIShip's identical-reship response
299// exactly as apicli.go sends it: still "result": "shipped" — 200 rather
300// than 201 is the ONLY signal that nothing new was written, so NoOp must be
301// keyed off status, not the result string.
302func TestAPISessionShipNoOp(t *testing.T) {
303 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
304 mr, err := r.MultipartReader()
305 if err != nil {
306 t.Errorf("request was not multipart: %v", err)
307 w.WriteHeader(http.StatusInternalServerError)
308 return
309 }
310 for {
311 _, err := mr.NextPart()
312 if err == io.EOF {
313 break
314 }
315 if err != nil {
316 t.Errorf("reading part: %v", err)
317 w.WriteHeader(http.StatusInternalServerError)
318 return
319 }
320 }
321 w.WriteHeader(http.StatusOK)
322 json.NewEncoder(w).Encode(map[string]any{
323 "result": "shipped", "version": "1.2.3", "artifacts": map[string]any{},
324 })
325 }))
326 defer ts.Close()
327
328 dir := t.TempDir()
329 file := filepath.Join(dir, "a.bin")
330 if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil {
331 t.Fatal(err)
332 }
333 s := newTestSession(ts.URL)
334 res, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", map[string]string{"alpha-amd64": file}, nil, false)
335 if err != nil {
336 t.Fatalf("ship: %v", err)
337 }
338 if !res.NoOp {
339 t.Fatalf("ship result = %+v, want NoOp true for a 200 identical re-ship", res)
340 }
341}
342
343func TestAPISessionShipErrorSurfacesServerMessage(t *testing.T) {
344 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
345 w.WriteHeader(http.StatusConflict)
346 json.NewEncoder(w).Encode(map[string]string{
347 "error": "version 1.2.3 was already shipped with different content",
348 })
349 }))
350 defer ts.Close()
351
352 dir := t.TempDir()
353 file := filepath.Join(dir, "a.bin")
354 if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil {
355 t.Fatal(err)
356 }
357 s := newTestSession(ts.URL)
358 _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", map[string]string{"alpha-amd64": file}, nil, false)
359 if err == nil || !strings.Contains(err.Error(), "already shipped with different content") {
360 t.Fatalf("ship error = %v, want the server's conflict message", err)
361 }
362}
363
364// TestAPISessionShipSurvivesSlowServer is the CRITICAL fix's proof: a slow
365// server holds the connection open longer than an old-style absolute client
366// Timeout would tolerate, but well within ship's own generous context.
367// shipHTTPClientTimeout (auth.go) is the injectable seam that lets both
368// halves of the point run fast, without an actual multi-minute sleep:
369// shrunk to a few milliseconds, the request fails exactly the way the
370// pre-fix code (which reused the JSON verbs' 30s-Timeout client) would have
371// on a real large upload; restored to its production zero value, the same
372// slow server succeeds because only the context bounds the request.
373func TestAPISessionShipSurvivesSlowServer(t *testing.T) {
374 t.Run("an absolute client Timeout fails against a slow server", func(t *testing.T) {
375 release := make(chan struct{})
376 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
377 <-release
378 io.Copy(io.Discard, r.Body)
379 w.WriteHeader(http.StatusCreated)
380 json.NewEncoder(w).Encode(map[string]any{"result": "shipped", "version": "1.2.3", "artifacts": map[string]any{}})
381 }))
382 defer func() {
383 close(release)
384 ts.Close()
385 }()
386
387 dir := t.TempDir()
388 file := filepath.Join(dir, "a.bin")
389 if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil {
390 t.Fatal(err)
391 }
392 s := newTestSession(ts.URL)
393
394 old := shipHTTPClientTimeout
395 shipHTTPClientTimeout = 30 * time.Millisecond
396 defer func() { shipHTTPClientTimeout = old }()
397
398 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
399 defer cancel()
400 _, err := s.ship(ctx, apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", map[string]string{"alpha-amd64": file}, nil, false)
401 if err == nil {
402 t.Fatal("ship with a tiny absolute client Timeout against a slow-to-answer server must fail")
403 }
404 })
405
406 t.Run("no absolute timeout survives the same slow server", func(t *testing.T) {
407 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
408 time.Sleep(150 * time.Millisecond) // longer than the tiny timeout above; well inside ship's context
409 io.Copy(io.Discard, r.Body)
410 w.WriteHeader(http.StatusCreated)
411 json.NewEncoder(w).Encode(map[string]any{"result": "shipped", "version": "1.2.3", "artifacts": map[string]any{}})
412 }))
413 defer ts.Close()
414
415 dir := t.TempDir()
416 file := filepath.Join(dir, "a.bin")
417 if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil {
418 t.Fatal(err)
419 }
420 s := newTestSession(ts.URL)
421 // shipHTTPClientTimeout is back at its zero production default here
422 // (the sibling subtest above restored it) — only the context bounds
423 // this call.
424 res, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", map[string]string{"alpha-amd64": file}, nil, false)
425 if err != nil {
426 t.Fatalf("ship against a slow-but-healthy server = %v, want success", err)
427 }
428 if res.NoOp {
429 t.Fatal("a fresh (201) ship must not report NoOp")
430 }
431 })
432}
433
434// TestAPISessionStableRefusal pins handleAPIPromote's 403 shape: the CLI
435// never resolves stable itself — it surfaces the console's own error AND the
436// console_url it hands back, because do() only keeps the bare "error"
437// string and a promote() caller needs the link too.
438func TestAPISessionStableRefusal(t *testing.T) {
439 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
440 w.WriteHeader(http.StatusForbidden)
441 json.NewEncoder(w).Encode(map[string]string{
442 "error": "stable needs a passkey — finish in the console",
443 "console_url": "http://x/a/b",
444 })
445 }))
446 defer ts.Close()
447
448 s := newTestSession(ts.URL)
449 _, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary", false, false)
450 if err == nil {
451 t.Fatal("promote against a stable refusal must error")
452 }
453 if !strings.Contains(err.Error(), "http://x/a/b") {
454 t.Fatalf("promote error %q does not contain the console_url", err)
455 }
456 if !strings.Contains(err.Error(), "passkey") {
457 t.Fatalf("promote error %q does not contain the console's refusal text", err)
458 }
459}
460
461func TestAPISessionRollbackStableRefusal(t *testing.T) {
462 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
463 w.WriteHeader(http.StatusForbidden)
464 json.NewEncoder(w).Encode(map[string]string{
465 "error": "stable needs a passkey — finish in the console",
466 "console_url": "http://x/a/b",
467 })
468 }))
469 defer ts.Close()
470
471 s := newTestSession(ts.URL)
472 err := s.rollback(context.Background(), apiAcct{Seg: "acct"}, "app", "stable")
473 if err == nil || !strings.Contains(err.Error(), "http://x/a/b") {
474 t.Fatalf("rollback error = %v, want it to contain the console_url", err)
475 }
476}
477
478func TestAPISessionPromoteSuccess(t *testing.T) {
479 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
480 if r.Header.Get("Authorization") != "Bearer cli-token" {
481 w.WriteHeader(http.StatusUnauthorized)
482 return
483 }
484 var req struct{ Version, Channel string }
485 json.NewDecoder(r.Body).Decode(&req)
486 if req.Version != "1.2.3" || req.Channel != "canary/a" {
487 t.Errorf("promote body = %+v", req)
488 w.WriteHeader(http.StatusInternalServerError)
489 return
490 }
491 json.NewEncoder(w).Encode(map[string]any{"result": "promoted", "from": "", "to": "canary"})
492 }))
493 defer ts.Close()
494
495 s := newTestSession(ts.URL)
496 res, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary/a", false, false)
497 if err != nil {
498 t.Fatalf("promote: %v", err)
499 }
500 if res.NoOp {
501 t.Fatal(`promote result = NoOp true, want false for a "promoted" result`)
502 }
503}
504
505// TestAPISessionPromoteNoOp pins the noop-parity fix: PromoteOnLadder
506// already found this version on this channel, and handleAPIPromote answers
507// {"result": "noop"} (still 200) — promote() must report that as NoOp true
508// rather than folding it into an ordinary success indistinguishable from an
509// actual promotion.
510func TestAPISessionPromoteNoOp(t *testing.T) {
511 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
512 json.NewEncoder(w).Encode(map[string]any{"result": "noop"})
513 }))
514 defer ts.Close()
515
516 s := newTestSession(ts.URL)
517 res, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary/a", false, false)
518 if err != nil {
519 t.Fatalf("promote: %v", err)
520 }
521 if !res.NoOp {
522 t.Fatalf(`promote result = %+v, want NoOp true for a "noop" result`, res)
523 }
524}
525
526// TestAPISessionPromoteUnshippedVersionSurfacesServerText pins the
527// IMPORTANT fix: a 404 with a real JSON diagnosis (an unshipped version,
528// handleAPIPromote's release.GetManifest check) must reach the caller
529// verbatim, not be swallowed into the generic membership message that only
530// belongs to apiAccountApp's gate.
531func TestAPISessionPromoteUnshippedVersionSurfacesServerText(t *testing.T) {
532 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
533 w.WriteHeader(http.StatusNotFound)
534 json.NewEncoder(w).Encode(map[string]string{
535 "error": "Version 9.9.9 is not a shipped release of app.",
536 })
537 }))
538 defer ts.Close()
539
540 s := newTestSession(ts.URL)
541 _, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "9.9.9", "canary/a", false, false)
542 if err == nil || !strings.Contains(err.Error(), "not a shipped release") {
543 t.Fatalf("promote 404 with a JSON diagnosis = %v, want the server's own text", err)
544 }
545 if strings.Contains(err.Error(), "check --account and --app") {
546 t.Fatalf("promote 404 with a real diagnosis must not be replaced by the membership message: %v", err)
547 }
548}
549
550// TestAPISessionPromoteAccountGateNotFound is the companion case: the SAME
551// 404 status, but apiAccountApp's gate (an unknown account/app or one the
552// caller doesn't belong to) — a text/plain http.NotFound body with no
553// "error" field at all — must still get the generic membership message.
554func TestAPISessionPromoteAccountGateNotFound(t *testing.T) {
555 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
556 http.NotFound(w, r)
557 }))
558 defer ts.Close()
559
560 s := newTestSession(ts.URL)
561 _, err := s.promote(context.Background(), apiAcct{Seg: "nope"}, "app", "1.2.3", "canary/a", false, false)
562 if err == nil || !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") {
563 t.Fatalf("promote against the account/app gate 404 = %v, want the membership message", err)
564 }
565}
566
567// TestAPISessionChannelsNotFound pins the 404 case the parent task called
568// out specifically: apiAccountApp's gate answers http.NotFound, a
569// text/plain body, not JSON — the client must not choke on that and must
570// leave the caller with a sane, actionable message instead of the raw
571// "404 page not found" body.
572func TestAPISessionChannelsNotFound(t *testing.T) {
573 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
574 http.NotFound(w, r)
575 }))
576 defer ts.Close()
577
578 s := newTestSession(ts.URL)
579 _, err := s.channels(context.Background(), apiAcct{Seg: "nope"}, "app")
580 if err == nil {
581 t.Fatal("channels against a 404 must error")
582 }
583 if !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") {
584 t.Fatalf("channels 404 error = %q, want a message naming --account/--app", err)
585 }
586}
587
588func TestAPISessionChannels(t *testing.T) {
589 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
590 json.NewEncoder(w).Encode(map[string]any{
591 "channels": []map[string]any{
592 {"channel": "canary/a", "version": "1.2.3", "promoted_at": "2026-08-03T00:00:00Z", "rung": "canary"},
593 {"channel": "broken", "error": "signature mismatch"},
594 },
595 })
596 }))
597 defer ts.Close()
598
599 s := newTestSession(ts.URL)
600 res, err := s.channels(context.Background(), apiAcct{Seg: "acct"}, "app")
601 if err != nil {
602 t.Fatalf("channels: %v", err)
603 }
604 chans := res.Channels
605 if len(chans) != 2 {
606 t.Fatalf("channels = %+v, want 2 rows", chans)
607 }
608 if chans[0].Channel != "canary/a" || chans[0].Version != "1.2.3" || chans[0].Rung != "canary" {
609 t.Fatalf("row 0 = %+v", chans[0])
610 }
611 if chans[1].Channel != "broken" || chans[1].Err != "signature mismatch" {
612 t.Fatalf("row 1 = %+v", chans[1])
613 }
614 // Neither "pipeline_entry" nor "pipeline_channels" present at all (an
615 // older console, a legacy app, or handleAPIChannels' own
616 // degrade-on-read-failure branch — this client has no need to tell
617 // those apart, apiChannelsResult's doc comment): both decode to their
618 // zero value.
619 if res.PipelineEntry != "" || len(res.PipelineChannels) != 0 {
620 t.Fatalf("no pipeline keys = %+v, want a zero-value pipeline", res)
621 }
622}
623
624// TestAPISessionChannelsPipelineEnvelope pins the client-side decode of the
625// additive "pipeline_entry"/"pipeline_channels" flat top-level keys (release
626// pipelines v2, Task 11, review fix round 1 — NOT nested under a "pipeline"
627// key, since that JSON path already names the full release.Pipeline object
628// on the `/pipeline` door): PipelineEntry/PipelineChannels decode from the
629// two flat fields when present, and — both keys absent, the shape a legacy
630// app OR an unreadable-pipeline app both get, this client has no need to
631// tell them apart — decode to the same zero value.
632func TestAPISessionChannelsPipelineEnvelope(t *testing.T) {
633 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
634 json.NewEncoder(w).Encode(map[string]any{
635 "channels": []map[string]any{},
636 "pipeline_entry": "edge",
637 "pipeline_channels": []string{"edge", "production"},
638 })
639 }))
640 defer ts.Close()
641
642 s := newTestSession(ts.URL)
643 res, err := s.channels(context.Background(), apiAcct{Seg: "acct"}, "app")
644 if err != nil {
645 t.Fatalf("channels: %v", err)
646 }
647 if res.PipelineEntry != "edge" {
648 t.Fatalf("PipelineEntry = %q, want edge", res.PipelineEntry)
649 }
650 if want := []string{"edge", "production"}; !equalStrings(res.PipelineChannels, want) {
651 t.Fatalf("PipelineChannels = %v, want %v", res.PipelineChannels, want)
652 }
653
654 ts2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
655 json.NewEncoder(w).Encode(map[string]any{"channels": []map[string]any{}})
656 }))
657 defer ts2.Close()
658 s2 := newTestSession(ts2.URL)
659 res2, err := s2.channels(context.Background(), apiAcct{Seg: "acct"}, "app")
660 if err != nil {
661 t.Fatalf("channels: %v", err)
662 }
663 if res2.PipelineEntry != "" || len(res2.PipelineChannels) != 0 {
664 t.Fatalf("both keys absent = %+v, want the zero value", res2)
665 }
666}
667
668func equalStrings(a, b []string) bool {
669 if len(a) != len(b) {
670 return false
671 }
672 for i := range a {
673 if a[i] != b[i] {
674 return false
675 }
676 }
677 return true
678}
679
680// TestInstanceListSendsFilters pins two things at once (Task 10): instanceList's
681// own request construction (q/health land as query params, total/truncated
682// decoded from the response) and apiPath's "?"+"&" join fix — an -account-id
683// (Exact) session must still produce a well-formed URL, with BOTH exact=sqid
684// and the filters, when the tail already carries a query string of its own.
685func TestInstanceListSendsFilters(t *testing.T) {
686 var gotPath string
687 var gotQuery url.Values
688 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
689 gotPath = r.URL.Path
690 gotQuery = r.URL.Query()
691 json.NewEncoder(w).Encode(map[string]any{"instances": []map[string]any{}, "truncated": false, "total": 0})
692 }))
693 defer ts.Close()
694
695 s := newTestSession(ts.URL)
696 _, truncated, total, err := s.instanceList(context.Background(), apiAcct{Seg: "bah", Exact: true}, "app", "api", "not-responding")
697 if err != nil {
698 t.Fatalf("instanceList: %v", err)
699 }
700 if truncated {
701 t.Fatal("truncated = true, want false")
702 }
703 if total != 0 {
704 t.Fatalf("total = %d, want 0", total)
705 }
706 if gotPath != "/api/cli/apps/bah/app/instances" {
707 t.Fatalf("path = %q, want /api/cli/apps/bah/app/instances", gotPath)
708 }
709 if got := gotQuery.Get("q"); got != "api" {
710 t.Fatalf("q = %q, want %q", got, "api")
711 }
712 if got := gotQuery.Get("health"); got != "not-responding" {
713 t.Fatalf("health = %q, want %q", got, "not-responding")
714 }
715 if got := gotQuery.Get("exact"); got != "sqid" {
716 t.Fatalf("exact = %q, want sqid — the account-id join must survive alongside q/health", got)
717 }
718}
719
720// TestInstanceListDecodesUpstreams pins the LIST join's wire shape (Task 8):
721// each record's "upstreams" array decodes into apiInstanceRecord.Upstreams,
722// and a record carrying no such key (the ordinary, single-upstream majority)
723// decodes to nil rather than an empty-but-present slice — the distinction
724// upstreamsCell's "-" rendering depends on.
725func TestInstanceListDecodesUpstreams(t *testing.T) {
726 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
727 json.NewEncoder(w).Encode(map[string]any{
728 "instances": []map[string]any{
729 {"host": "up.example.com", "upstreams": []map[string]any{
730 {"addr": "10.0.0.1:8090", "up": true},
731 {"addr": "10.0.0.2:8090", "up": false},
732 }},
733 {"host": "solo.example.com"},
734 },
735 "truncated": false, "total": 2,
736 })
737 }))
738 defer ts.Close()
739
740 s := newTestSession(ts.URL)
741 recs, _, _, err := s.instanceList(context.Background(), apiAcct{Seg: "bah"}, "app", "", "")
742 if err != nil {
743 t.Fatalf("instanceList: %v", err)
744 }
745 if len(recs) != 2 {
746 t.Fatalf("recs = %+v, want 2", recs)
747 }
748 if len(recs[0].Upstreams) != 2 || !recs[0].Upstreams[0].Up || recs[0].Upstreams[1].Up {
749 t.Fatalf("recs[0].Upstreams = %+v, want [10.0.0.1:8090 up, 10.0.0.2:8090 down]", recs[0].Upstreams)
750 }
751 if recs[1].Upstreams != nil {
752 t.Fatalf("recs[1].Upstreams = %+v, want nil for a record with no upstreams key", recs[1].Upstreams)
753 }
754}
755
756// TestInstanceCreateSendsAddrsAndProbe pins instanceCreate's request body
757// (Task 8): -addr/-health-path/-health-status land as addrs/probe on the
758// POST, alongside host/region/channel exactly as before, and are OMITTED
759// entirely (not sent as null or empty) when the caller passes neither —
760// today's create request, byte for byte, for a caller that never heard of
761// the new flags.
762func TestInstanceCreateSendsAddrsAndProbe(t *testing.T) {
763 var gotBody string
764 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
765 if r.Method != http.MethodPost {
766 t.Errorf("method = %s, want POST", r.Method)
767 }
768 b, _ := io.ReadAll(r.Body)
769 gotBody = string(b)
770 w.WriteHeader(http.StatusCreated)
771 json.NewEncoder(w).Encode(map[string]any{"host": "a.example.com", "channel": "stable"})
772 }))
773 defer ts.Close()
774
775 s := newTestSession(ts.URL)
776 rec, err := s.instanceCreate(context.Background(), apiAcct{Seg: "bah"}, "app",
777 "a.example.com", "", "", "", "", []string{"10.0.0.1:8090", "10.0.0.2:8090"},
778 &instanceProbeReq{Path: "/healthz", Status: 200}, nil)
779 if err != nil {
780 t.Fatalf("instanceCreate: %v", err)
781 }
782 if rec.Host != "a.example.com" || rec.Channel != "stable" {
783 t.Fatalf("rec = %+v", rec)
784 }
785 var got struct {
786 Addrs []string `json:"addrs"`
787 Probe struct {
788 Path string `json:"path"`
789 Status int `json:"status"`
790 } `json:"probe"`
791 }
792 if err := json.Unmarshal([]byte(gotBody), &got); err != nil {
793 t.Fatalf("request body unmarshal: %v\nbody: %s", err, gotBody)
794 }
795 if len(got.Addrs) != 2 || got.Addrs[0] != "10.0.0.1:8090" || got.Addrs[1] != "10.0.0.2:8090" {
796 t.Fatalf("request addrs = %+v", got.Addrs)
797 }
798 if got.Probe.Path != "/healthz" || got.Probe.Status != 200 {
799 t.Fatalf("request probe = %+v", got.Probe)
800 }
801
802 // No addrs/probe given at all: neither key crosses the wire.
803 gotBody = ""
804 if _, err := s.instanceCreate(context.Background(), apiAcct{Seg: "bah"}, "app",
805 "b.example.com", "", "", "", "", nil, nil, nil); err != nil {
806 t.Fatalf("instanceCreate (no addrs/probe): %v", err)
807 }
808 if strings.Contains(gotBody, "addrs") || strings.Contains(gotBody, "probe") {
809 t.Fatalf("request body = %q, want no addrs/probe key when neither was given", gotBody)
810 }
811}
812
813// TestInstanceSetUpstreamsPatches pins instanceSetUpstreams' whole contract
814// (Task 8, the CLI front over Task 7's PATCH): the HTTP method, the path
815// (mirrors instanceDelete's /instances/<host> escaping), the addrs/probe
816// wire shape, AND the three-way "absent vs explicit-empty vs a real value"
817// distinction the PATCH door depends on — a nil *[]string must send no
818// "addrs" key at all, while a non-nil pointer to an empty slice must send
819// "addrs":[] (the door's own clear spelling, apiinstances.go).
820func TestInstanceSetUpstreamsPatches(t *testing.T) {
821 var gotMethod, gotPath, gotBody string
822 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
823 gotMethod, gotPath = r.Method, r.URL.Path
824 b, _ := io.ReadAll(r.Body)
825 gotBody = string(b)
826 json.NewEncoder(w).Encode(map[string]any{"host": "a.example.com"})
827 }))
828 defer ts.Close()
829
830 s := newTestSession(ts.URL)
831 addrs := []string{"10.0.0.1:8090", "10.0.0.2:8090"}
832 rec, err := s.instanceSetUpstreams(context.Background(), apiAcct{Seg: "bah"}, "app", "a.example.com",
833 &addrs, &instanceProbeReq{Path: "/up", Status: 204})
834 if err != nil {
835 t.Fatalf("instanceSetUpstreams: %v", err)
836 }
837 if rec.Host != "a.example.com" {
838 t.Fatalf("rec = %+v", rec)
839 }
840 if gotMethod != http.MethodPatch {
841 t.Fatalf("method = %s, want PATCH", gotMethod)
842 }
843 if gotPath != "/api/cli/apps/bah/app/instances/a.example.com" {
844 t.Fatalf("path = %q", gotPath)
845 }
846 var got struct {
847 Addrs []string `json:"addrs"`
848 Probe struct {
849 Path string `json:"path"`
850 Status int `json:"status"`
851 } `json:"probe"`
852 }
853 if err := json.Unmarshal([]byte(gotBody), &got); err != nil {
854 t.Fatalf("request body unmarshal: %v\nbody: %s", err, gotBody)
855 }
856 if len(got.Addrs) != 2 || got.Addrs[1] != "10.0.0.2:8090" {
857 t.Fatalf("request addrs = %+v", got.Addrs)
858 }
859 if got.Probe.Path != "/up" || got.Probe.Status != 204 {
860 t.Fatalf("request probe = %+v", got.Probe)
861 }
862
863 // addrs=nil (flag not given): no "addrs" key at all — "leave it alone".
864 gotBody = ""
865 if _, err := s.instanceSetUpstreams(context.Background(), apiAcct{Seg: "bah"}, "app", "a.example.com",
866 nil, nil); err != nil {
867 t.Fatalf("instanceSetUpstreams (nil addrs): %v", err)
868 }
869 if strings.Contains(gotBody, "addrs") {
870 t.Fatalf("request body = %q, want no addrs key for a nil pointer", gotBody)
871 }
872
873 // addrs=&[]string{} (an explicit -addrs '' clear): "addrs":[] on the wire.
874 gotBody = ""
875 empty := []string{}
876 if _, err := s.instanceSetUpstreams(context.Background(), apiAcct{Seg: "bah"}, "app", "a.example.com",
877 &empty, nil); err != nil {
878 t.Fatalf("instanceSetUpstreams (empty addrs): %v", err)
879 }
880 if !strings.Contains(gotBody, `"addrs":[]`) {
881 t.Fatalf("request body = %q, want an explicit \"addrs\":[]", gotBody)
882 }
883}
884
885// TestInstanceSetUpstreamsSurfacesRefusalVerbatim pins the self-review rule
886// this task carries: the PATCH door's own refusal text (ValidateShape's or
887// the "nothing to update" 400) reaches the caller byte-for-byte — it names
888// the offending upstream or the missing field, which is the only thing an
889// operator can act on, and paraphrasing it here would throw that away.
890func TestInstanceSetUpstreamsSurfacesRefusalVerbatim(t *testing.T) {
891 const wantMsg = "addr \"garbage\": want a TCP upstream as host:port (e.g. 127.0.0.1:8090)"
892 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
893 w.WriteHeader(http.StatusBadRequest)
894 json.NewEncoder(w).Encode(map[string]any{"error": wantMsg})
895 }))
896 defer ts.Close()
897
898 s := newTestSession(ts.URL)
899 addrs := []string{"garbage"}
900 _, err := s.instanceSetUpstreams(context.Background(), apiAcct{Seg: "bah"}, "app", "a.example.com", &addrs, nil)
901 if err == nil {
902 t.Fatal("instanceSetUpstreams: want an error, got nil")
903 }
904 if err.Error() != wantMsg {
905 t.Fatalf("error = %q, want the door's own message %q verbatim", err.Error(), wantMsg)
906 }
907}
908
909// TestInstanceSetSteeringPatches pins instanceSetSteering's whole contract
910// (Task 3, multi-region-steering plan): the HTTP method, the path (the same
911// /instances/<host> door set-upstreams uses), and — unlike addrs/probe —
912// that steering is ALWAYS sent on the wire, "" included, since this verb has
913// no "leave it alone" spelling: every invocation states the value the
914// caller wants.
915func TestInstanceSetSteeringPatches(t *testing.T) {
916 var gotMethod, gotPath, gotBody string
917 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
918 gotMethod, gotPath = r.Method, r.URL.Path
919 b, _ := io.ReadAll(r.Body)
920 gotBody = string(b)
921 json.NewEncoder(w).Encode(map[string]any{"host": "a.example.com", "steering": "latency"})
922 }))
923 defer ts.Close()
924
925 s := newTestSession(ts.URL)
926 rec, err := s.instanceSetSteering(context.Background(), apiAcct{Seg: "bah"}, "app", "a.example.com", "latency")
927 if err != nil {
928 t.Fatalf("instanceSetSteering: %v", err)
929 }
930 if rec.Host != "a.example.com" || rec.Steering != "latency" {
931 t.Fatalf("rec = %+v", rec)
932 }
933 if gotMethod != http.MethodPatch {
934 t.Fatalf("method = %s, want PATCH", gotMethod)
935 }
936 if gotPath != "/api/cli/apps/bah/app/instances/a.example.com" {
937 t.Fatalf("path = %q", gotPath)
938 }
939 if gotBody != `{"steering":"latency"}` {
940 t.Fatalf("request body = %q, want a bare steering key", gotBody)
941 }
942
943 // Turning it off: the wire must still carry the key, `"steering":""`, not
944 // omit it — an omitted key means "leave it alone" to the door, which
945 // would silently do nothing for `carlos steering off`.
946 gotBody = ""
947 if _, err := s.instanceSetSteering(context.Background(), apiAcct{Seg: "bah"}, "app", "a.example.com", ""); err != nil {
948 t.Fatalf("instanceSetSteering (off): %v", err)
949 }
950 if gotBody != `{"steering":""}` {
951 t.Fatalf("request body = %q, want an explicit empty steering key", gotBody)
952 }
953}
954
955// TestInstanceSetSteeringSurfacesRefusalVerbatim pins the same self-review
956// rule set-upstreams carries: the door's own refusal — the pool 409 or
957// ValidateShape's 400 — reaches the caller byte-for-byte, since it names
958// the only thing an operator can act on and paraphrasing it here would
959// throw that away.
960func TestInstanceSetSteeringSurfacesRefusalVerbatim(t *testing.T) {
961 const wantMsg = "steering requires the shared pool — pool-scoped steering is a recorded future"
962 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
963 w.WriteHeader(http.StatusConflict)
964 json.NewEncoder(w).Encode(map[string]any{"error": wantMsg})
965 }))
966 defer ts.Close()
967
968 s := newTestSession(ts.URL)
969 _, err := s.instanceSetSteering(context.Background(), apiAcct{Seg: "bah"}, "app", "a.example.com", "latency")
970 if err == nil {
971 t.Fatal("instanceSetSteering: want an error, got nil")
972 }
973 if err.Error() != wantMsg {
974 t.Fatalf("error = %q, want the door's own message %q verbatim", err.Error(), wantMsg)
975 }
976}
977
978// ---- Task 5: the four verbs wired to resolveMode --------------------------
979
980// loginTo saves one live credential for origin/address and returns it, so a
981// verb-level test can log in without going through `carlos auth login`.
982func loginTo(t *testing.T, origin, address string) {
983 t.Helper()
984 f, err := loadCredentials()
985 if err != nil {
986 t.Fatal(err)
987 }
988 f.put(credential{Console: origin, Address: address, Token: "cli-token", ExpiresAt: time.Now().Add(time.Hour)})
989 if err := f.save(); err != nil {
990 t.Fatal(err)
991 }
992}
993
994// apiTestAccount is the -account value every API-mode verb test below uses.
995// Deliberately NOT sqid-shaped and not "ops": every prior round of these
996// tests happened to pass an -account value ("acct") that resolveAccount
997// would have resolved WITHOUT error had it wrongly been called (release.
998// ValidAccount("acct") happens to be true, so a stray resolveAccount call
999// would have silently "worked" and gone unnoticed). "vickys-team" fails
1000// that same check, so a regression that starts calling acctOf()/
1001// resolveAccount in API mode again is loud (log.Fatal, or at minimum the
1002// registry.db file requireNoRegistryTouch below watches for) rather than
1003// silently passing.
1004const apiTestAccount = "vickys-team"
1005
1006// requireNoRegistryTouch points CARLOS_DATA_DIR at a fresh, empty temp
1007// directory and returns a func the caller runs AFTER the verb under test:
1008// it fails the test if registry.db appeared there. accountFlags' resolver
1009// (resolveAccount) is the only thing in this file that ever opens the
1010// registry, and registry.Open creates the file on first open even if
1011// resolution goes on to fail — so its presence is direct, mechanical proof
1012// that API mode reached into the local registry, which it must never do
1013// (there is no registry on a developer laptop to reach into).
1014func requireNoRegistryTouch(t *testing.T) func() {
1015 t.Helper()
1016 dir := t.TempDir()
1017 t.Setenv("CARLOS_DATA_DIR", dir)
1018 return func() {
1019 t.Helper()
1020 path := filepath.Join(dir, "registry.db")
1021 if _, err := os.Stat(path); err == nil {
1022 t.Fatalf("%s was created — API mode must never open the local registry", path)
1023 } else if !os.IsNotExist(err) {
1024 t.Fatalf("stat %s: %v", path, err)
1025 }
1026 }
1027}
1028
1029// newShipFlagSet builds the -account/-account-id/-registry flags
1030// runShipAPI's callers register in production (accountFlags), parses account
1031// plus the given positional file/dir args exactly as fs.Parse(os.Args)
1032// would, and returns the FlagSet and the two raw account pointers runShipAPI
1033// expects — letting a test drive runShipAPI directly (bypassing runShip's
1034// own flag set and, crucially, its log.Fatal on error) so a refusal can be
1035// asserted as a returned error instead of a process exit.
1036func newShipFlagSet(t *testing.T, account string, args ...string) (*flag.FlagSet, *string, *string) {
1037 t.Helper()
1038 fs := flag.NewFlagSet("ship", flag.ContinueOnError)
1039 acctRaw, acctIDRaw, _ := accountFlags(fs)
1040 full := append([]string{"-account", account}, args...)
1041 if err := fs.Parse(full); err != nil {
1042 t.Fatalf("parse: %v", err)
1043 }
1044 return fs, acctRaw, acctIDRaw
1045}
1046
1047// TestShipVerbAPIMode pins runShip's API-mode wiring end to end: with a live
1048// credential and neither store env set, it must ship through the console
1049// (not openStore/release.Ship) and print the first-use "using <origin> as
1050// <address>" line ahead of the ordinary "shipped" line — no artifact count,
1051// per the noop-parity amendment, since the API-mode message is deliberately
1052// its own shape.
1053func TestShipVerbAPIMode(t *testing.T) {
1054 useTempCredentials(t)
1055 clearDeploymentEnv(t)
1056 checkNoRegistry := requireNoRegistryTouch(t)
1057
1058 var reqCount int32
1059 mux := http.NewServeMux()
1060 mux.HandleFunc("POST /api/cli/apps/"+apiTestAccount+"/titogo/releases", func(w http.ResponseWriter, r *http.Request) {
1061 atomic.AddInt32(&reqCount, 1)
1062 mr, err := r.MultipartReader()
1063 if err != nil {
1064 t.Errorf("not multipart: %v", err)
1065 w.WriteHeader(http.StatusInternalServerError)
1066 return
1067 }
1068 for {
1069 _, err := mr.NextPart()
1070 if err == io.EOF {
1071 break
1072 }
1073 if err != nil {
1074 t.Errorf("part: %v", err)
1075 w.WriteHeader(http.StatusInternalServerError)
1076 return
1077 }
1078 }
1079 w.WriteHeader(http.StatusCreated)
1080 json.NewEncoder(w).Encode(map[string]any{
1081 "result": "shipped", "version": "abc1234", "artifacts": map[string]any{},
1082 })
1083 })
1084 ts := httptest.NewServer(mux)
1085 defer ts.Close()
1086
1087 loginTo(t, ts.URL, "vicky@keymail.dev")
1088
1089 dir := t.TempDir()
1090 bin := filepath.Join(dir, "carlos")
1091 if err := os.WriteFile(bin, []byte("binary bytes"), 0o755); err != nil {
1092 t.Fatal(err)
1093 }
1094
1095 buf := captureStdout(t)
1096 runShip([]string{"-app", "titogo", "-account", apiTestAccount, "-version", "abc1234", "-target", "linux-arm64", bin})
1097
1098 out := buf.String()
1099 if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) {
1100 t.Fatalf("stdout = %q, want it to contain %q", out, want)
1101 }
1102 if !strings.Contains(out, "shipped titogo abc1234") {
1103 t.Fatalf("stdout = %q, want the shipped line", out)
1104 }
1105 if got := atomic.LoadInt32(&reqCount); got != 1 {
1106 t.Fatalf("server got %d requests, want 1", got)
1107 }
1108 checkNoRegistry()
1109}
1110
1111// TestShipVerbAPIModeNoOp pins the ship side of the noop-parity amendment: a
1112// 200 (rather than 201) response is a re-ship of identical content, and the
1113// verb must print the specified "already shipped ... nothing to do" line, not
1114// the ordinary "shipped" line.
1115func TestShipVerbAPIModeNoOp(t *testing.T) {
1116 useTempCredentials(t)
1117 clearDeploymentEnv(t)
1118 checkNoRegistry := requireNoRegistryTouch(t)
1119
1120 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1121 mr, err := r.MultipartReader()
1122 if err != nil {
1123 t.Fatalf("not multipart: %v", err)
1124 }
1125 for {
1126 if _, err := mr.NextPart(); err == io.EOF {
1127 break
1128 } else if err != nil {
1129 t.Fatalf("part: %v", err)
1130 }
1131 }
1132 w.WriteHeader(http.StatusOK)
1133 json.NewEncoder(w).Encode(map[string]any{
1134 "result": "shipped", "version": "abc1234", "artifacts": map[string]any{},
1135 })
1136 }))
1137 defer ts.Close()
1138
1139 loginTo(t, ts.URL, "vicky@keymail.dev")
1140
1141 dir := t.TempDir()
1142 bin := filepath.Join(dir, "carlos")
1143 if err := os.WriteFile(bin, []byte("binary bytes"), 0o755); err != nil {
1144 t.Fatal(err)
1145 }
1146
1147 buf := captureStdout(t)
1148 runShip([]string{"-app", "titogo", "-account", apiTestAccount, "-version", "abc1234", "-target", "linux-arm64", bin})
1149
1150 out := buf.String()
1151 if want := "already shipped titogo abc1234 — identical content, nothing to do"; !strings.Contains(out, want) {
1152 t.Fatalf("stdout = %q, want %q", out, want)
1153 }
1154 if strings.Contains(out, "shipped titogo abc1234\n") {
1155 t.Fatalf("stdout = %q, must not ALSO print the ordinary shipped line", out)
1156 }
1157 checkNoRegistry()
1158}
1159
1160// TestPromoteVerbAPIMode pins runPromote's API-mode wiring: it must promote
1161// through the console and print the SAME "promoted" line the direct path
1162// prints (cli.go's runPromote default case) — the verb, not the transport,
1163// owns that wording.
1164func TestPromoteVerbAPIMode(t *testing.T) {
1165 useTempCredentials(t)
1166 clearDeploymentEnv(t)
1167 checkNoRegistry := requireNoRegistryTouch(t)
1168
1169 var reqCount int32
1170 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1171 atomic.AddInt32(&reqCount, 1)
1172 if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/promote" {
1173 t.Errorf("path = %s", r.URL.Path)
1174 }
1175 var req struct{ Version, Channel string }
1176 json.NewDecoder(r.Body).Decode(&req)
1177 if req.Version != "abc1234" || req.Channel != "canary/x" {
1178 t.Errorf("promote body = %+v", req)
1179 }
1180 json.NewEncoder(w).Encode(map[string]any{"result": "promoted", "from": "", "to": ""})
1181 }))
1182 defer ts.Close()
1183
1184 loginTo(t, ts.URL, "vicky@keymail.dev")
1185
1186 buf := captureStdout(t)
1187 runPromote([]string{"-app", "titogo", "-account", apiTestAccount, "abc1234", "canary/x"})
1188
1189 out := buf.String()
1190 if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) {
1191 t.Fatalf("stdout = %q, want it to contain %q", out, want)
1192 }
1193 if want := "promoted titogo abc1234 -> canary/x"; !strings.Contains(out, want) {
1194 t.Fatalf("stdout = %q, want %q", out, want)
1195 }
1196 if got := atomic.LoadInt32(&reqCount); got != 1 {
1197 t.Fatalf("server got %d requests, want 1", got)
1198 }
1199 checkNoRegistry()
1200}
1201
1202// TestPromoteVerbAPIModeAccountID pins -account-id's transport on a POST
1203// route end to end (the GET side is covered server-side by apicli_test.go,
1204// which drives only GETs): the sqid must land in the path segment AND the
1205// request must carry ?exact=sqid, the console-side switch that stops the
1206// segment from ever being name-matched. -account is set to a colliding
1207// value at the same time to pin the supersede rule — the sqid wins, and the
1208// name never reaches the wire.
1209func TestPromoteVerbAPIModeAccountID(t *testing.T) {
1210 useTempCredentials(t)
1211 clearDeploymentEnv(t)
1212 checkNoRegistry := requireNoRegistryTouch(t)
1213
1214 var reqCount int32
1215 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1216 atomic.AddInt32(&reqCount, 1)
1217 if r.URL.Path != "/api/cli/apps/bah/titogo/promote" {
1218 t.Errorf("path = %s, want the --account-id sqid as the segment", r.URL.Path)
1219 }
1220 if got := r.URL.Query().Get("exact"); got != "sqid" {
1221 t.Errorf("exact = %q, want %q — --account-id must never be name-matched", got, "sqid")
1222 }
1223 json.NewEncoder(w).Encode(map[string]any{"result": "promoted"})
1224 }))
1225 defer ts.Close()
1226
1227 loginTo(t, ts.URL, "vicky@keymail.dev")
1228
1229 buf := captureStdout(t)
1230 runPromote([]string{"-app", "titogo", "-account", apiTestAccount, "-account-id", "bah", "abc1234", "canary/x"})
1231
1232 if want := "promoted titogo abc1234 -> canary/x"; !strings.Contains(buf.String(), want) {
1233 t.Fatalf("stdout = %q, want %q", buf.String(), want)
1234 }
1235 if got := atomic.LoadInt32(&reqCount); got != 1 {
1236 t.Fatalf("server got %d requests, want 1", got)
1237 }
1238 checkNoRegistry()
1239}
1240
1241// TestPromoteVerbAPIModeNoOp pins the noop-parity amendment itself: API-mode
1242// promote must print the byte-identical "already on X — nothing to do" shape
1243// direct mode prints (runPromote's res.NoOp branch), not a differently-worded
1244// API-mode-only message.
1245func TestPromoteVerbAPIModeNoOp(t *testing.T) {
1246 useTempCredentials(t)
1247 clearDeploymentEnv(t)
1248 checkNoRegistry := requireNoRegistryTouch(t)
1249
1250 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1251 json.NewEncoder(w).Encode(map[string]any{"result": "noop"})
1252 }))
1253 defer ts.Close()
1254
1255 loginTo(t, ts.URL, "vicky@keymail.dev")
1256
1257 buf := captureStdout(t)
1258 runPromote([]string{"-app", "titogo", "-account", apiTestAccount, "abc1234", "canary/x"})
1259
1260 out := buf.String()
1261 if want := "titogo abc1234 is already on canary/x — nothing to do"; !strings.Contains(out, want) {
1262 t.Fatalf("stdout = %q, want %q", out, want)
1263 }
1264 checkNoRegistry()
1265}
1266
1267// TestChannelsVerbAPIMode pins runChannels' API-mode wiring: it must read the
1268// ladder through the console and print the same tabwriter table direct mode
1269// prints, ERROR rows included (apiChannel.Err mirrors runChannels' per-row
1270// ERROR behaviour, per apimode.go's doc comment) — plus the TAG column, which
1271// comes from a SECOND read (GET .../tags) joined client-side, so the channels
1272// endpoint's own wire shape stays untouched (release versioning spec,
1273// 2026-08-08).
1274func TestChannelsVerbAPIMode(t *testing.T) {
1275 useTempCredentials(t)
1276 clearDeploymentEnv(t)
1277 checkNoRegistry := requireNoRegistryTouch(t)
1278
1279 base := "/api/cli/apps/" + apiTestAccount + "/titogo"
1280 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1281 switch r.URL.Path {
1282 case base + "/channels":
1283 json.NewEncoder(w).Encode(map[string]any{
1284 "channels": []map[string]any{
1285 {"channel": "canary/a", "version": "1.2.3", "promoted_at": "2026-08-03T00:00:00Z", "rung": "canary"},
1286 {"channel": "stable", "version": "0.5.0-3", "promoted_at": "2026-08-04T00:00:00Z", "rung": "stable"},
1287 {"channel": "broken", "error": "signature mismatch"},
1288 },
1289 })
1290 case base + "/tags":
1291 json.NewEncoder(w).Encode(map[string]any{
1292 "tags": []map[string]any{
1293 {"version": "0.5.0", "release": "0.5.0-3", "tagged_at": "2026-08-04T00:00:00Z", "actor": "vicky@keymail.dev"},
1294 },
1295 })
1296 default:
1297 t.Errorf("path = %s", r.URL.Path)
1298 }
1299 }))
1300 defer ts.Close()
1301
1302 loginTo(t, ts.URL, "vicky@keymail.dev")
1303
1304 buf := captureStdout(t)
1305 runChannels([]string{"-app", "titogo", "-account", apiTestAccount})
1306
1307 out := buf.String()
1308 if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) {
1309 t.Fatalf("stdout = %q, want it to contain %q", out, want)
1310 }
1311 if !strings.Contains(out, "CHANNEL") || !strings.Contains(out, "VERSION") || !strings.Contains(out, "PROMOTED") || !strings.Contains(out, "TAG") {
1312 t.Fatalf("stdout = %q, want the tabwriter header", out)
1313 }
1314 if !strings.Contains(out, "canary/a") || !strings.Contains(out, "1.2.3") {
1315 t.Fatalf("stdout = %q, want the canary/a row", out)
1316 }
1317 if !strings.Contains(out, "broken") || !strings.Contains(out, "ERROR: signature mismatch") {
1318 t.Fatalf("stdout = %q, want the broken row's ERROR text", out)
1319 }
1320 // The tag lands on the row serving the release it was cut from, and on no
1321 // other row: the join is release -> tag, not "the newest tag wins".
1322 var stableRow, canaryRow string
1323 for _, line := range strings.Split(out, "\n") {
1324 switch {
1325 case strings.HasPrefix(line, "stable"):
1326 stableRow = strings.TrimRight(line, " ")
1327 case strings.HasPrefix(line, "canary/a"):
1328 canaryRow = strings.TrimRight(line, " ")
1329 }
1330 }
1331 // The DIGESTS cell closes every row now; the tag is the cell before it.
1332 if !strings.HasSuffix(stableRow, "0.5.0 "+textStateNo) {
1333 t.Fatalf("stable row = %q, want the tag 0.5.0 then the DIGESTS cell", stableRow)
1334 }
1335 if strings.Contains(canaryRow, "0.5.0") {
1336 t.Fatalf("canary row = %q, want no tag on an untagged version", canaryRow)
1337 }
1338 checkNoRegistry()
1339}
1340
1341// TestVersionTargetVerbAPIMode pins runVersionTarget's API-mode wiring in all
1342// three states it has: unset (a 200 with an empty target, NOT a 404), set,
1343// and the write — which must be a PUT carrying {"target"}, the shape
1344// handleAPIVersionTargetSet (internal/console/apiversion.go) reads.
1345func TestVersionTargetVerbAPIMode(t *testing.T) {
1346 useTempCredentials(t)
1347 clearDeploymentEnv(t)
1348 checkNoRegistry := requireNoRegistryTouch(t)
1349
1350 path := "/api/cli/apps/" + apiTestAccount + "/titogo/version-target"
1351 var stored string
1352 var gotPutBody string
1353 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1354 if r.URL.Path != path {
1355 t.Errorf("path = %s", r.URL.Path)
1356 return
1357 }
1358 switch r.Method {
1359 case http.MethodGet:
1360 if stored == "" {
1361 json.NewEncoder(w).Encode(map[string]string{"target": ""})
1362 return
1363 }
1364 json.NewEncoder(w).Encode(map[string]any{
1365 "target": stored, "set_by": "vicky@keymail.dev", "set_at": "2026-08-08T00:00:00Z",
1366 })
1367 case http.MethodPut:
1368 b, _ := io.ReadAll(r.Body)
1369 gotPutBody = strings.TrimSpace(string(b))
1370 var req struct {
1371 Target string `json:"target"`
1372 }
1373 json.Unmarshal(b, &req)
1374 stored = req.Target
1375 json.NewEncoder(w).Encode(map[string]string{"target": stored})
1376 default:
1377 t.Errorf("method = %s", r.Method)
1378 }
1379 }))
1380 defer ts.Close()
1381
1382 loginTo(t, ts.URL, "vicky@keymail.dev")
1383
1384 buf := captureStdout(t)
1385 runVersionTarget([]string{"-app", "titogo", "-account", apiTestAccount})
1386 if out := buf.String(); !strings.Contains(out, "no version target set") {
1387 t.Fatalf("unset target stdout = %q", out)
1388 }
1389
1390 buf = captureStdout(t)
1391 runVersionTarget([]string{"-app", "titogo", "-account", apiTestAccount, "0.5.0"})
1392 if out := buf.String(); !strings.Contains(out, "version target 0.5.0") {
1393 t.Fatalf("set stdout = %q", out)
1394 }
1395 if want := `{"target":"0.5.0"}`; gotPutBody != want {
1396 t.Fatalf("PUT body = %q, want %q", gotPutBody, want)
1397 }
1398
1399 buf = captureStdout(t)
1400 runVersionTarget([]string{"-app", "titogo", "-account", apiTestAccount})
1401 out := buf.String()
1402 if !strings.Contains(out, "version target 0.5.0") {
1403 t.Fatalf("get-after-set stdout = %q", out)
1404 }
1405 if !strings.Contains(out, "set by vicky@keymail.dev") {
1406 t.Fatalf("get-after-set stdout = %q, want the provenance line", out)
1407 }
1408 checkNoRegistry()
1409}
1410
1411// TestReleasesVerbAPIMode pins runReleases' API-mode wiring: one GET, the
1412// server's row ORDER preserved verbatim (the console already sorts newest
1413// first — re-sorting client-side would be a second opinion nobody asked
1414// for), and every column rendered.
1415func TestReleasesVerbAPIMode(t *testing.T) {
1416 useTempCredentials(t)
1417 clearDeploymentEnv(t)
1418 checkNoRegistry := requireNoRegistryTouch(t)
1419
1420 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1421 if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/releases" {
1422 t.Errorf("path = %s", r.URL.Path)
1423 return
1424 }
1425 json.NewEncoder(w).Encode(map[string]any{
1426 "releases": []map[string]any{
1427 {"version": "0.5.0-4", "kind": "binary", "built_at": "2026-08-08T10:00:00Z", "label": "and the sidebar", "tag": ""},
1428 {"version": "0.5.0-3", "kind": "binary", "built_at": "2026-08-07T10:00:00Z", "label": "fixing modals", "tag": "0.5.0"},
1429 },
1430 })
1431 }))
1432 defer ts.Close()
1433
1434 loginTo(t, ts.URL, "vicky@keymail.dev")
1435
1436 buf := captureStdout(t)
1437 runReleases([]string{"-app", "titogo", "-account", apiTestAccount})
1438
1439 out := buf.String()
1440 lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
1441 if len(lines) < 4 {
1442 t.Fatalf("stdout = %q, want a using line, a header and two rows", out)
1443 }
1444 if !strings.Contains(lines[1], "VERSION") || !strings.Contains(lines[1], "LABEL") || !strings.Contains(lines[1], "TAG") {
1445 t.Fatalf("header = %q", lines[1])
1446 }
1447 if !strings.Contains(lines[2], "0.5.0-4") || !strings.Contains(lines[2], "and the sidebar") {
1448 t.Fatalf("first row = %q, want the server's first row", lines[2])
1449 }
1450 if !strings.Contains(lines[3], "0.5.0-3") || !strings.HasSuffix(strings.TrimRight(lines[3], " "), "0.5.0") {
1451 t.Fatalf("second row = %q, want the tagged row carrying its tag", lines[3])
1452 }
1453 checkNoRegistry()
1454}
1455
1456// TestReleasesVerbAPIModeEmpty: an app with nothing shipped gets a sentence,
1457// not a bare header — the empty table would otherwise read as a failure.
1458func TestReleasesVerbAPIModeEmpty(t *testing.T) {
1459 useTempCredentials(t)
1460 clearDeploymentEnv(t)
1461 checkNoRegistry := requireNoRegistryTouch(t)
1462
1463 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1464 json.NewEncoder(w).Encode(map[string]any{"releases": []map[string]any{}})
1465 }))
1466 defer ts.Close()
1467
1468 loginTo(t, ts.URL, "vicky@keymail.dev")
1469
1470 buf := captureStdout(t)
1471 runReleases([]string{"-app", "titogo", "-account", apiTestAccount})
1472 if out := buf.String(); !strings.Contains(out, "no releases yet") {
1473 t.Fatalf("stdout = %q, want the empty-state line", out)
1474 }
1475 checkNoRegistry()
1476}
1477
1478// TestRollbackVerbAPIMode pins runRollback's API-mode wiring.
1479func TestRollbackVerbAPIMode(t *testing.T) {
1480 useTempCredentials(t)
1481 clearDeploymentEnv(t)
1482 checkNoRegistry := requireNoRegistryTouch(t)
1483
1484 var reqCount int32
1485 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1486 atomic.AddInt32(&reqCount, 1)
1487 if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/rollback" {
1488 t.Errorf("path = %s", r.URL.Path)
1489 }
1490 var req struct{ Channel string }
1491 json.NewDecoder(r.Body).Decode(&req)
1492 if req.Channel != "canary/x" {
1493 t.Errorf("rollback body = %+v", req)
1494 }
1495 json.NewEncoder(w).Encode(map[string]string{"result": "rolled back"})
1496 }))
1497 defer ts.Close()
1498
1499 loginTo(t, ts.URL, "vicky@keymail.dev")
1500
1501 buf := captureStdout(t)
1502 runRollback([]string{"-app", "titogo", "-account", apiTestAccount, "canary/x"})
1503
1504 out := buf.String()
1505 if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) {
1506 t.Fatalf("stdout = %q, want it to contain %q", out, want)
1507 }
1508 if want := "rolled back titogo canary/x"; !strings.Contains(out, want) {
1509 t.Fatalf("stdout = %q, want %q", out, want)
1510 }
1511 if got := atomic.LoadInt32(&reqCount); got != 1 {
1512 t.Fatalf("server got %d requests, want 1", got)
1513 }
1514 checkNoRegistry()
1515}
1516
1517// TestPromoteVerbDirectUnchanged pins the other half of resolveMode's
1518// contract: with CARLOS_DEPLOYMENT_DIR set, runPromote must take the existing
1519// direct-bucket path unchanged and never dial a console at all — even one it
1520// holds a live (and, if it mistakenly used it, perfectly usable) credential
1521// for. The httptest server's request counter is the proof: it must stay 0.
1522func TestPromoteVerbDirectUnchanged(t *testing.T) {
1523 useTempCredentials(t)
1524 clearDeploymentEnv(t)
1525
1526 var reqCount int32
1527 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1528 atomic.AddInt32(&reqCount, 1)
1529 w.WriteHeader(http.StatusInternalServerError)
1530 }))
1531 defer ts.Close()
1532 // A live credential pointed AT the counting server: direct mode must win
1533 // regardless (CARLOS_DEPLOYMENT_DIR set below), so this must never be
1534 // dialed — if it ever were, reqCount would catch it.
1535 loginTo(t, ts.URL, "vicky@keymail.dev")
1536
1537 dir := t.TempDir()
1538 t.Setenv("CARLOS_DEPLOYMENT_DIR", dir)
1539
1540 seed, pub, err := release.GenerateKey()
1541 if err != nil {
1542 t.Fatal(err)
1543 }
1544 t.Setenv("CARLOS_RELEASE_KEY", seed)
1545 t.Setenv("CARLOS_RELEASE_PUBKEY", pub)
1546
1547 bin := filepath.Join(t.TempDir(), "carlos")
1548 if err := os.WriteFile(bin, []byte("binary bytes"), 0o755); err != nil {
1549 t.Fatal(err)
1550 }
1551
1552 buf := captureStdout(t)
1553 runShip([]string{"-app", "demoapp", "-version", "abc1234", "-target", "linux-arm64", bin})
1554 runPromote([]string{"-app", "demoapp", "abc1234", "canary/x"})
1555
1556 out := buf.String()
1557 // Pins ship's direct-mode print format exactly (review round, MINOR 8):
1558 // unlike API mode's deliberately different "shipped <app> <version>"
1559 // (no count), direct mode still names the artifact count — this must
1560 // stay byte-identical to what it printed before Task 5 touched this verb.
1561 if want := "shipped demoapp abc1234 (1 artifacts)"; !strings.Contains(out, want) {
1562 t.Fatalf("stdout = %q, want the direct-mode shipped line", out)
1563 }
1564 if want := "promoted demoapp abc1234 -> canary/x"; !strings.Contains(out, want) {
1565 t.Fatalf("stdout = %q, want the direct-mode promoted line", out)
1566 }
1567 if got := atomic.LoadInt32(&reqCount); got != 0 {
1568 t.Fatalf("server got %d requests, want 0 — direct mode must never dial the console", got)
1569 }
1570}
1571
1572// ---- Task 5 review round: runShipAPI's static/multi-file/pre-flight paths -
1573
1574// TestShipAPIStaticNamesSiteTarGz pins runShipAPI's static branch end to end
1575// (review round, IMPORTANT 2 — previously zero coverage): the packed tarball
1576// must reach the server in a part named EXACTLY "site.tar.gz", carrying the
1577// same bytes edge.PackDir itself produces for the same directory — proof the
1578// scratch-temp-file plumbing (PackDir -> temp file -> multipart part) does
1579// not mangle anything in between.
1580func TestShipAPIStaticNamesSiteTarGz(t *testing.T) {
1581 useTempCredentials(t)
1582 clearDeploymentEnv(t)
1583 checkNoRegistry := requireNoRegistryTouch(t)
1584
1585 siteDir := t.TempDir()
1586 if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("<h1>hi</h1>"), 0o644); err != nil {
1587 t.Fatal(err)
1588 }
1589 wantBytes, err := edge.PackDir(siteDir)
1590 if err != nil {
1591 t.Fatal(err)
1592 }
1593
1594 var gotName string
1595 var gotBytes []byte
1596 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1597 mr, err := r.MultipartReader()
1598 if err != nil {
1599 t.Fatalf("not multipart: %v", err)
1600 }
1601 for {
1602 part, err := mr.NextPart()
1603 if err == io.EOF {
1604 break
1605 }
1606 if err != nil {
1607 t.Fatalf("part: %v", err)
1608 }
1609 b, err := io.ReadAll(part)
1610 if err != nil {
1611 t.Fatalf("read part: %v", err)
1612 }
1613 if part.FormName() != "meta" {
1614 gotName = part.FormName()
1615 gotBytes = b
1616 }
1617 }
1618 w.WriteHeader(http.StatusCreated)
1619 json.NewEncoder(w).Encode(map[string]any{"result": "shipped", "version": "abc1234", "artifacts": map[string]any{}})
1620 }))
1621 defer ts.Close()
1622
1623 sess := newTestSession(ts.URL)
1624 fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, siteDir)
1625 if _, err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", "", "", "", nil, true, false); err != nil {
1626 t.Fatalf("runShipAPI: %v", err)
1627 }
1628
1629 if gotName != "site.tar.gz" {
1630 t.Fatalf("server received artifact part named %q, want %q", gotName, "site.tar.gz")
1631 }
1632 if !bytes.Equal(gotBytes, wantBytes) {
1633 t.Fatalf("server received %d bytes, want the %d bytes edge.PackDir produced for the same directory", len(gotBytes), len(wantBytes))
1634 }
1635 checkNoRegistry()
1636}
1637
1638// TestShipAPIStaticCleansUpTempFileOnServerError pins the temp-file-leak fix
1639// (review round, MINOR 4): runShipAPI used to call log.Fatal on a failing
1640// sess.ship, which calls os.Exit and skips every deferred function —
1641// including the static kind's scratch temp file cleanup, up to 256MiB per
1642// invocation. Returning an error instead (the caller log.Fatals) lets that
1643// defer actually run.
1644func TestShipAPIStaticCleansUpTempFileOnServerError(t *testing.T) {
1645 useTempCredentials(t)
1646 clearDeploymentEnv(t)
1647 checkNoRegistry := requireNoRegistryTouch(t)
1648
1649 siteDir := t.TempDir()
1650 if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("hi"), 0o644); err != nil {
1651 t.Fatal(err)
1652 }
1653
1654 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1655 io.Copy(io.Discard, r.Body)
1656 w.WriteHeader(http.StatusInternalServerError)
1657 }))
1658 defer ts.Close()
1659
1660 tmpParent := os.TempDir()
1661 before, err := os.ReadDir(tmpParent)
1662 if err != nil {
1663 t.Fatal(err)
1664 }
1665 beforeNames := map[string]bool{}
1666 for _, e := range before {
1667 beforeNames[e.Name()] = true
1668 }
1669
1670 sess := newTestSession(ts.URL)
1671 fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, siteDir)
1672 if _, err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", "", "", "", nil, true, false); err == nil {
1673 t.Fatal("runShipAPI against a failing server must error")
1674 }
1675
1676 after, err := os.ReadDir(tmpParent)
1677 if err != nil {
1678 t.Fatal(err)
1679 }
1680 for _, e := range after {
1681 if beforeNames[e.Name()] {
1682 continue
1683 }
1684 if strings.HasPrefix(e.Name(), "carlos-ship-") {
1685 t.Fatalf("temp file %s leaked after a failed ship", e.Name())
1686 }
1687 }
1688 checkNoRegistry()
1689}
1690
1691// TestShipAPIMultiFileGoodBasenames pins the multi-file binary ship path
1692// (review round, IMPORTANT 2 + MINOR 7's good case): every basename shaped
1693// like a GOOS-GOARCH target ships as its own part, named exactly its
1694// basename.
1695func TestShipAPIMultiFileGoodBasenames(t *testing.T) {
1696 useTempCredentials(t)
1697 clearDeploymentEnv(t)
1698 checkNoRegistry := requireNoRegistryTouch(t)
1699
1700 dir := t.TempDir()
1701 fileArm := filepath.Join(dir, "linux-arm64")
1702 fileAmd := filepath.Join(dir, "linux-amd64")
1703 if err := os.WriteFile(fileArm, []byte("arm bytes"), 0o755); err != nil {
1704 t.Fatal(err)
1705 }
1706 if err := os.WriteFile(fileAmd, []byte("amd bytes"), 0o755); err != nil {
1707 t.Fatal(err)
1708 }
1709
1710 var reqCount int32
1711 gotParts := map[string]bool{}
1712 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1713 atomic.AddInt32(&reqCount, 1)
1714 mr, err := r.MultipartReader()
1715 if err != nil {
1716 t.Fatalf("not multipart: %v", err)
1717 }
1718 for {
1719 part, err := mr.NextPart()
1720 if err == io.EOF {
1721 break
1722 }
1723 if err != nil {
1724 t.Fatalf("part: %v", err)
1725 }
1726 io.Copy(io.Discard, part)
1727 if part.FormName() != "meta" {
1728 gotParts[part.FormName()] = true
1729 }
1730 }
1731 w.WriteHeader(http.StatusCreated)
1732 json.NewEncoder(w).Encode(map[string]any{"result": "shipped", "version": "abc1234", "artifacts": map[string]any{}})
1733 }))
1734 defer ts.Close()
1735
1736 sess := newTestSession(ts.URL)
1737 fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, fileArm, fileAmd)
1738 if _, err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", "", "", nil, true, false); err != nil {
1739 t.Fatalf("runShipAPI: %v", err)
1740 }
1741
1742 if !gotParts["linux-arm64"] || !gotParts["linux-amd64"] || len(gotParts) != 2 {
1743 t.Fatalf("server received parts %v, want exactly linux-arm64 and linux-amd64", gotParts)
1744 }
1745 if got := atomic.LoadInt32(&reqCount); got != 1 {
1746 t.Fatalf("server got %d requests, want 1", got)
1747 }
1748 checkNoRegistry()
1749}
1750
1751// TestShipAPIMultiFileBadBasenameRefused pins the client-side name check
1752// (review round, MINOR 7's bad case): a multi-file binary ship whose
1753// basename doesn't fit the server's own GOOS-GOARCH shape (targetRe,
1754// internal/console/apicli.go) must be refused before ever dialing the
1755// console, naming the offending file.
1756func TestShipAPIMultiFileBadBasenameRefused(t *testing.T) {
1757 useTempCredentials(t)
1758 clearDeploymentEnv(t)
1759 checkNoRegistry := requireNoRegistryTouch(t)
1760
1761 dir := t.TempDir()
1762 fileGood := filepath.Join(dir, "linux-arm64")
1763 fileBad := filepath.Join(dir, "output.bin")
1764 if err := os.WriteFile(fileGood, []byte("arm bytes"), 0o755); err != nil {
1765 t.Fatal(err)
1766 }
1767 if err := os.WriteFile(fileBad, []byte("bad bytes"), 0o755); err != nil {
1768 t.Fatal(err)
1769 }
1770
1771 var reqCount int32
1772 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1773 atomic.AddInt32(&reqCount, 1)
1774 w.WriteHeader(http.StatusInternalServerError)
1775 }))
1776 defer ts.Close()
1777
1778 sess := newTestSession(ts.URL)
1779 fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, fileGood, fileBad)
1780 _, err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", "", "", nil, true, false)
1781 if err == nil {
1782 t.Fatal("runShipAPI with a bad basename must error")
1783 }
1784 if !strings.Contains(err.Error(), "output.bin") {
1785 t.Fatalf("error %q does not name the offending file", err)
1786 }
1787 if !strings.Contains(err.Error(), "invalid artifact name") {
1788 t.Fatalf("error %q does not explain the refusal", err)
1789 }
1790 if got := atomic.LoadInt32(&reqCount); got != 0 {
1791 t.Fatalf("server got %d requests, want 0 — a bad basename must be refused before dialing the console", got)
1792 }
1793 checkNoRegistry()
1794}
1795
1796// TestShipAPIMissingArtifactRefusedBeforeDialing pins the pre-flight stat
1797// (review round, MINOR 6): a typo'd artifact path must fail clean before the
1798// console is ever dialed, not after.
1799func TestShipAPIMissingArtifactRefusedBeforeDialing(t *testing.T) {
1800 useTempCredentials(t)
1801 clearDeploymentEnv(t)
1802 checkNoRegistry := requireNoRegistryTouch(t)
1803
1804 missing := filepath.Join(t.TempDir(), "linux-arm64") // never created
1805
1806 var reqCount int32
1807 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1808 atomic.AddInt32(&reqCount, 1)
1809 w.WriteHeader(http.StatusInternalServerError)
1810 }))
1811 defer ts.Close()
1812
1813 sess := newTestSession(ts.URL)
1814 fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, missing)
1815 _, err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", "", "", nil, true, false)
1816 if err == nil {
1817 t.Fatal("runShipAPI with a missing artifact path must error")
1818 }
1819 if got := atomic.LoadInt32(&reqCount); got != 0 {
1820 t.Fatalf("server got %d requests, want 0 — a missing path must be refused before dialing the console", got)
1821 }
1822 checkNoRegistry()
1823}
1824
1825// TestAPISessionShipCarriesCapabilitiesInMeta pins the API-mode half of the
1826// declaration surface: the block rides the EXISTING meta part (so the server
1827// has read the whole declaration before it reads an artifact byte and can
1828// refuse the upload at its first part), as the CANONICAL re-marshal of the
1829// decoded Set rather than the operator's file verbatim — bounded and
1830// deterministic, which is what keeps it comfortably inside the 8KiB
1831// io.LimitReader handleAPIShip reads meta under.
1832//
1833// TestAPISessionShipMultipart above pins the other half by omission: with no
1834// capabilities the meta part is still exactly {"version":..,"kind":..}, byte
1835// for byte what this verb sent before the field existed.
1836func TestAPISessionShipCarriesCapabilitiesInMeta(t *testing.T) {
1837 var metaBody string
1838 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1839 mr, err := r.MultipartReader()
1840 if err != nil {
1841 t.Errorf("request was not multipart: %v", err)
1842 w.WriteHeader(http.StatusInternalServerError)
1843 return
1844 }
1845 for {
1846 p, err := mr.NextPart()
1847 if err != nil {
1848 break
1849 }
1850 b, _ := io.ReadAll(p)
1851 if p.FormName() == "meta" {
1852 metaBody = string(b)
1853 }
1854 }
1855 w.WriteHeader(http.StatusCreated)
1856 // A CURRENT console echoes the stored block back (2026-08-03 review,
1857 // I3); this verb refuses a success that does not, so the fake has to
1858 // behave like the console it stands in for.
1859 w.Write([]byte(`{"result":"shipped","capabilities":{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}]}}`))
1860 }))
1861 defer ts.Close()
1862
1863 dir := t.TempDir()
1864 file := filepath.Join(dir, "a")
1865 if err := os.WriteFile(file, []byte("A"), 0o644); err != nil {
1866 t.Fatal(err)
1867 }
1868
1869 var set *caps.Set
1870 if err := json.Unmarshal([]byte(`{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}]}`), &set); err != nil {
1871 t.Fatal(err)
1872 }
1873
1874 s := newTestSession(ts.URL)
1875 if _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "",
1876 map[string]string{"alpha-amd64": file}, set, false); err != nil {
1877 t.Fatalf("ship: %v", err)
1878 }
1879 want := `{"version":"1.2.3","kind":"binary","capabilities":{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}]}}`
1880 if metaBody != want {
1881 t.Fatalf("meta part = %q, want %q", metaBody, want)
1882 }
1883}
1884
1885// TestAPISessionShipCarriesProgramChangeInMeta: --program-change rides in the
1886// meta part as "program_change":true, and is omitted when false, so a ship
1887// without the flag sends the bytes this verb sent before the flag existed.
1888func TestAPISessionShipCarriesProgramChangeInMeta(t *testing.T) {
1889 for _, tc := range []struct {
1890 change bool
1891 want string
1892 }{
1893 {true, `{"version":"1.2.3","kind":"binary","program_change":true}`},
1894 {false, `{"version":"1.2.3","kind":"binary"}`},
1895 } {
1896 var metaBody string
1897 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1898 mr, err := r.MultipartReader()
1899 if err != nil {
1900 t.Errorf("request was not multipart: %v", err)
1901 w.WriteHeader(http.StatusInternalServerError)
1902 return
1903 }
1904 for {
1905 p, err := mr.NextPart()
1906 if err != nil {
1907 break
1908 }
1909 b, _ := io.ReadAll(p)
1910 if p.FormName() == "meta" {
1911 metaBody = string(b)
1912 }
1913 }
1914 w.WriteHeader(http.StatusCreated)
1915 w.Write([]byte(`{"result":"shipped"}`))
1916 }))
1917 s := newTestSession(ts.URL)
1918 _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "",
1919 shipTestArtifact(t), nil, tc.change)
1920 ts.Close()
1921 if err != nil {
1922 t.Fatalf("ship (program_change %v): %v", tc.change, err)
1923 }
1924 if metaBody != tc.want {
1925 t.Fatalf("meta part (program_change %v) = %q, want %q", tc.change, metaBody, tc.want)
1926 }
1927 }
1928}
1929
1930// shipEchoServer is a console stand-in for the capability-echo tests: it
1931// accepts any multipart ship and answers 201 with the body given.
1932func shipEchoServer(t *testing.T, body string) *httptest.Server {
1933 t.Helper()
1934 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1935 mr, err := r.MultipartReader()
1936 if err != nil {
1937 t.Errorf("request was not multipart: %v", err)
1938 w.WriteHeader(http.StatusInternalServerError)
1939 return
1940 }
1941 for {
1942 p, err := mr.NextPart()
1943 if err != nil {
1944 break
1945 }
1946 io.Copy(io.Discard, p)
1947 }
1948 w.WriteHeader(http.StatusCreated)
1949 w.Write([]byte(body))
1950 }))
1951}
1952
1953// shipTestArtifact writes one throwaway artifact file and returns the files
1954// map ship takes.
1955func shipTestArtifact(t *testing.T) map[string]string {
1956 t.Helper()
1957 file := filepath.Join(t.TempDir(), "a")
1958 if err := os.WriteFile(file, []byte("A"), 0o644); err != nil {
1959 t.Fatal(err)
1960 }
1961 return map[string]string{"alpha-amd64": file}
1962}
1963
1964// shipTestCaps is the declaration the echo tests send.
1965func shipTestCaps(t *testing.T) *caps.Set {
1966 t.Helper()
1967 var set *caps.Set
1968 if err := json.Unmarshal([]byte(`{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}]}`), &set); err != nil {
1969 t.Fatal(err)
1970 }
1971 return set
1972}
1973
1974// TestAPISessionShipAcceptsAMatchingCapabilitiesEcho is the ordinary path: a
1975// current console stores what was sent and echoes it, so the ship succeeds.
1976// The echo check must not turn every capability-bearing ship into a refusal.
1977func TestAPISessionShipAcceptsAMatchingCapabilitiesEcho(t *testing.T) {
1978 ts := shipEchoServer(t, `{"result":"shipped","version":"1.2.3","capabilities":{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}]}}`)
1979 defer ts.Close()
1980
1981 s := newTestSession(ts.URL)
1982 res, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", shipTestArtifact(t), shipTestCaps(t), false)
1983 if err != nil {
1984 t.Fatalf("ship with a matching echo: %v", err)
1985 }
1986 if res.NoOp {
1987 t.Fatal("a 201 ship must not report NoOp")
1988 }
1989}
1990
1991// TestAPISessionShipRefusesAConsoleThatDropsCapabilities is the REMOVAL
1992// signal, simulated exactly as an un-upgraded console produces it: the server
1993// ignores the unknown "capabilities" key (Go's decoder does this by default),
1994// stores a manifest declaring nothing, and answers 201 with no echo. That
1995// manifest is the instruction to DELETE the drop-ins on every box — so a 201
1996// here has to be a hard failure at the terminal, naming the fix, rather than a
1997// success the operator then promotes.
1998func TestAPISessionShipRefusesAConsoleThatDropsCapabilities(t *testing.T) {
1999 ts := shipEchoServer(t, `{"result":"shipped","version":"1.2.3","artifacts":{}}`)
2000 defer ts.Close()
2001
2002 s := newTestSession(ts.URL)
2003 _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", shipTestArtifact(t), shipTestCaps(t), false)
2004 if err == nil {
2005 t.Fatal("a console that echoed no capability block was accepted — that is an old console silently dropping the declaration")
2006 }
2007 if !strings.Contains(err.Error(), "update the console binary") {
2008 t.Errorf("refusal does not name the fix: %v", err)
2009 }
2010}
2011
2012// TestAPISessionShipRefusesADifferentCapabilitiesEcho: a console that
2013// understood the key but stored something else. The STORED block is what boxes
2014// converge onto, so a mismatch is refused too, and the message shows both —
2015// there is no version of this where the CLI's copy is the one that matters.
2016func TestAPISessionShipRefusesADifferentCapabilitiesEcho(t *testing.T) {
2017 ts := shipEchoServer(t, `{"result":"shipped","version":"1.2.3","capabilities":{"v":1,"daemon":true}}`)
2018 defer ts.Close()
2019
2020 s := newTestSession(ts.URL)
2021 _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", shipTestArtifact(t), shipTestCaps(t), false)
2022 if err == nil {
2023 t.Fatal("a console that stored a DIFFERENT capability block was accepted")
2024 }
2025 for _, want := range []string{"differs", "port", "must not be promoted"} {
2026 if !strings.Contains(err.Error(), want) {
2027 t.Errorf("refusal %q does not mention %q", err, want)
2028 }
2029 }
2030}
2031
2032// TestAPISessionShipWithoutCapabilitiesNeedsNoEcho: the fleet's ordinary ship
2033// sends no block and must not start demanding one back. An echo check that
2034// forgot to be conditional would refuse every ship the fleet does today.
2035func TestAPISessionShipWithoutCapabilitiesNeedsNoEcho(t *testing.T) {
2036 ts := shipEchoServer(t, `{"result":"shipped","version":"1.2.3","artifacts":{}}`)
2037 defer ts.Close()
2038
2039 s := newTestSession(ts.URL)
2040 if _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", shipTestArtifact(t), nil, false); err != nil {
2041 t.Fatalf("ship without --capabilities must not require an echo: %v", err)
2042 }
2043}
2044
2045// ---- Task 6: carlos ledger — apiSession methods and the CLI verbs --------
2046
2047// TestAPISessionLedgerAppend pins the wire shape handleAPILedgerAppend
2048// (internal/console/apiledger.go) requires: bearer auth, POST to
2049// .../ledgers/<name>/append, body {"payload": <verbatim>}.
2050func TestAPISessionLedgerAppend(t *testing.T) {
2051 var gotAuth, gotPath, gotBody string
2052 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2053 gotAuth = r.Header.Get("Authorization")
2054 gotPath = r.URL.Path
2055 b, _ := io.ReadAll(r.Body)
2056 gotBody = string(b)
2057 json.NewEncoder(w).Encode(ledger.Appended{Key: "ledgers/acct/app/carbon/entries/x.json", SHA256: strings.Repeat("a", 64), Count: 3})
2058 }))
2059 defer ts.Close()
2060
2061 s := newTestSession(ts.URL)
2062 a, err := s.ledgerAppend(context.Background(), apiAcct{Seg: "acct"}, "app", "carbon", json.RawMessage(`{"n":1}`))
2063 if err != nil {
2064 t.Fatalf("ledgerAppend: %v", err)
2065 }
2066 if gotAuth != "Bearer cli-token" {
2067 t.Fatalf("Authorization = %q, want Bearer cli-token", gotAuth)
2068 }
2069 if gotPath != "/api/cli/apps/acct/app/ledgers/carbon/append" {
2070 t.Fatalf("path = %q", gotPath)
2071 }
2072 if !strings.Contains(gotBody, `"payload":{"n":1}`) {
2073 t.Fatalf("request body = %q, want the payload carried verbatim", gotBody)
2074 }
2075 if a.Count != 3 {
2076 t.Fatalf("Count = %d, want 3", a.Count)
2077 }
2078}
2079
2080// TestAPISessionLedgerAppendBusy pins ledgerStoreError's contention status:
2081// a 503 with a JSON "error" body must surface that text, not a generic
2082// status-only message — it is what tells a caller "retry", not "stop".
2083func TestAPISessionLedgerAppendBusy(t *testing.T) {
2084 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2085 w.WriteHeader(http.StatusServiceUnavailable)
2086 json.NewEncoder(w).Encode(map[string]string{"error": "the ledger is busy right now — try again in a moment"})
2087 }))
2088 defer ts.Close()
2089
2090 s := newTestSession(ts.URL)
2091 _, err := s.ledgerAppend(context.Background(), apiAcct{Seg: "acct"}, "app", "carbon", json.RawMessage(`{"n":1}`))
2092 if err == nil || !strings.Contains(err.Error(), "busy") {
2093 t.Fatalf("ledgerAppend 503 error = %v, want it to surface the busy message", err)
2094 }
2095}
2096
2097// TestAPISessionLedgerHeadNotFound pins ledgerHead's DEDICATED 404 message —
2098// deliberately not apiErrorFromBody's generic "-account/-app" text, since an
2099// unknown LEDGER under an app the caller does belong to is a different, far
2100// more common case ("nothing appended yet").
2101func TestAPISessionLedgerHeadNotFound(t *testing.T) {
2102 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2103 http.NotFound(w, r)
2104 }))
2105 defer ts.Close()
2106
2107 s := newTestSession(ts.URL)
2108 _, err := s.ledgerHead(context.Background(), apiAcct{Seg: "acct"}, "app", "carbon")
2109 if err == nil {
2110 t.Fatal("ledgerHead against a 404 must error")
2111 }
2112 if !strings.Contains(err.Error(), `"carbon"`) {
2113 t.Fatalf("error = %q, want it to name the ledger", err)
2114 }
2115 if strings.Contains(err.Error(), "-account") {
2116 t.Fatalf("error = %q, must NOT fall back to the generic membership message", err)
2117 }
2118}
2119
2120func TestAPISessionLedgerHead(t *testing.T) {
2121 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2122 json.NewEncoder(w).Encode(ledger.Head{Schema: 1, UpdatedAt: "2026-08-03T00:00:00Z", HeadKey: "k", HeadSHA256: strings.Repeat("b", 64), Count: 5})
2123 }))
2124 defer ts.Close()
2125
2126 s := newTestSession(ts.URL)
2127 h, err := s.ledgerHead(context.Background(), apiAcct{Seg: "acct"}, "app", "carbon")
2128 if err != nil {
2129 t.Fatalf("ledgerHead: %v", err)
2130 }
2131 if h.Count != 5 || h.HeadKey != "k" {
2132 t.Fatalf("head = %+v", h)
2133 }
2134}
2135
2136// TestAPISessionLedgerList pins ledgerList's raw-body contract: the caller
2137// (runLedgerList) decodes {"manifest","ledgers"} itself, so a null manifest
2138// survives round-trip as nil rather than a caller-side zero value that would
2139// erase the "never published" distinction.
2140func TestAPISessionLedgerList(t *testing.T) {
2141 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2142 json.NewEncoder(w).Encode(map[string]any{"manifest": nil, "ledgers": []string{"carbon"}})
2143 }))
2144 defer ts.Close()
2145
2146 s := newTestSession(ts.URL)
2147 body, err := s.ledgerList(context.Background(), apiAcct{Seg: "acct"}, "app")
2148 if err != nil {
2149 t.Fatalf("ledgerList: %v", err)
2150 }
2151 var res struct {
2152 Manifest *ledger.Manifest `json:"manifest"`
2153 Ledgers []string `json:"ledgers"`
2154 }
2155 if err := json.Unmarshal(body, &res); err != nil {
2156 t.Fatalf("unmarshal: %v", err)
2157 }
2158 if res.Manifest != nil {
2159 t.Fatalf("manifest = %+v, want nil", res.Manifest)
2160 }
2161 if len(res.Ledgers) != 1 || res.Ledgers[0] != "carbon" {
2162 t.Fatalf("ledgers = %v, want [carbon]", res.Ledgers)
2163 }
2164}
2165
2166// TestAPISessionLedgerPublish: publish is read-modify-write server-side, so
2167// the answer is the RESULTING manifest — the set that is public now, which
2168// may name ledgers this request never mentioned. A method that discarded the
2169// body would leave the CLI with nothing honest to print.
2170func TestAPISessionLedgerPublish(t *testing.T) {
2171 var gotPath, gotBody string
2172 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2173 gotPath = r.URL.Path
2174 b, _ := io.ReadAll(r.Body)
2175 gotBody = string(b)
2176 json.NewEncoder(w).Encode(ledger.Manifest{
2177 Schema: 1, Public: true, Ledgers: []string{"prompts", "carbon"}, Title: "T"})
2178 }))
2179 defer ts.Close()
2180
2181 s := newTestSession(ts.URL)
2182 m, err := s.ledgerPublish(context.Background(), apiAcct{Seg: "acct"}, "app",
2183 ledger.Manifest{Public: true, Ledgers: []string{"carbon"}, Title: "T"})
2184 if err != nil {
2185 t.Fatalf("ledgerPublish: %v", err)
2186 }
2187 if !m.Public || len(m.Ledgers) != 2 || m.Ledgers[1] != "carbon" {
2188 t.Fatalf("ledgerPublish = %+v, want the server's RESULTING manifest, not the request", m)
2189 }
2190 if gotPath != "/api/cli/apps/acct/app/ledgers/publish" {
2191 t.Fatalf("path = %q", gotPath)
2192 }
2193 if !strings.Contains(gotBody, `"public":true`) || !strings.Contains(gotBody, `"carbon"`) {
2194 t.Fatalf("body = %q", gotBody)
2195 }
2196}
2197
2198// TestAPISessionLedgerBlob pins ledgerBlob's request plumbing — the one
2199// method that hand-builds its request rather than going through
2200// getJSON/postJSON, because the wire body is raw bytes, not JSON. Same base
2201// URL and bearer header as getJSON, POST instead of GET, a bytes.Reader body
2202// instead of nil.
2203func TestAPISessionLedgerBlob(t *testing.T) {
2204 var gotAuth, gotPath string
2205 var gotBody []byte
2206 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2207 gotAuth = r.Header.Get("Authorization")
2208 gotPath = r.URL.Path
2209 gotBody, _ = io.ReadAll(r.Body)
2210 json.NewEncoder(w).Encode(map[string]string{"sha256": ledger.SHA256Hex(gotBody)})
2211 }))
2212 defer ts.Close()
2213
2214 s := newTestSession(ts.URL)
2215 sha, err := s.ledgerBlob(context.Background(), apiAcct{Seg: "acct"}, "app", "carbon", []byte("corpus"))
2216 if err != nil {
2217 t.Fatalf("ledgerBlob: %v", err)
2218 }
2219 if gotAuth != "Bearer cli-token" {
2220 t.Fatalf("Authorization = %q, want Bearer cli-token", gotAuth)
2221 }
2222 if gotPath != "/api/cli/apps/acct/app/ledgers/carbon/blobs" {
2223 t.Fatalf("path = %q", gotPath)
2224 }
2225 if string(gotBody) != "corpus" {
2226 t.Fatalf("request body = %q, want the raw bytes verbatim", gotBody)
2227 }
2228 if want := ledger.SHA256Hex([]byte("corpus")); sha != want {
2229 t.Fatalf("sha256 = %q, want %q", sha, want)
2230 }
2231}
2232
2233// TestAPISessionLedgerBlobError pins ledgerBlob's error path — a non-200
2234// status decodes through apiErrorFromBody exactly like every other method
2235// here, even though the request itself was raw bytes, not JSON.
2236func TestAPISessionLedgerBlobError(t *testing.T) {
2237 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2238 w.WriteHeader(http.StatusRequestEntityTooLarge)
2239 json.NewEncoder(w).Encode(map[string]string{"error": "blob exceeds the 4 MiB cap"})
2240 }))
2241 defer ts.Close()
2242
2243 s := newTestSession(ts.URL)
2244 _, err := s.ledgerBlob(context.Background(), apiAcct{Seg: "acct"}, "app", "carbon", []byte("x"))
2245 if err == nil || !strings.Contains(err.Error(), "4 MiB cap") {
2246 t.Fatalf("ledgerBlob error = %v, want the server's 413 message surfaced", err)
2247 }
2248}
2249
2250// TestLedgerVerbsOfferNoRegistryFlag: -registry names the local database
2251// that resolves an account in DIRECT-bucket mode, and ledgerAPISession
2252// refuses direct mode outright (spec §2.3). A ledger verb that advertised
2253// -registry would be offering a mode it will then refuse — and the flag
2254// would be dead besides, since nothing on this path opens a registry (the
2255// requireNoRegistryTouch assertions elsewhere in this file are the other
2256// half of that proof).
2257func TestLedgerVerbsOfferNoRegistryFlag(t *testing.T) {
2258 fs := flag.NewFlagSet("ledger", flag.ContinueOnError)
2259 ledgerAccountFlags(fs)
2260 if fs.Lookup("account") == nil {
2261 t.Fatal("ledger verbs must still take --account")
2262 }
2263 // -account-id is the way out of an ambiguity the console reports (409),
2264 // so a ledger verb that offered -account without it would name a fix-up
2265 // flag its own caller cannot type.
2266 if fs.Lookup("account-id") == nil {
2267 t.Fatal("ledger verbs must take --account-id — it is what the console's ambiguity 409 tells the caller to use")
2268 }
2269 if f := fs.Lookup("registry"); f != nil {
2270 t.Fatalf("ledger verbs register --registry (%q) — direct-bucket mode is refused, not configured", f.Usage)
2271 }
2272}
2273
2274// ---- Task 6: the ledger verbs wired to resolveMode / public HTTPS --------
2275
2276// TestLedgerAppendVerbAPIMode pins runLedgerAppend's console wiring: it
2277// reads the entry from a file argument, validates it client-side (already
2278// smoke-tested via `go run . ledger append` — invalid JSON never reaches
2279// this server, see the task report), and posts through the console.
2280func TestLedgerAppendVerbAPIMode(t *testing.T) {
2281 useTempCredentials(t)
2282 clearDeploymentEnv(t)
2283 checkNoRegistry := requireNoRegistryTouch(t)
2284
2285 var reqCount int32
2286 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2287 atomic.AddInt32(&reqCount, 1)
2288 if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers/carbon/append" {
2289 t.Errorf("path = %s", r.URL.Path)
2290 }
2291 var in struct {
2292 Payload json.RawMessage `json:"payload"`
2293 }
2294 json.NewDecoder(r.Body).Decode(&in)
2295 if string(in.Payload) != `{"n":1}` {
2296 t.Errorf("payload = %s, want {\"n\":1}", in.Payload)
2297 }
2298 json.NewEncoder(w).Encode(ledger.Appended{Key: "k", SHA256: strings.Repeat("c", 64), Count: 7})
2299 }))
2300 defer ts.Close()
2301
2302 loginTo(t, ts.URL, "vicky@keymail.dev")
2303
2304 dir := t.TempDir()
2305 entryPath := filepath.Join(dir, "entry.json")
2306 if err := os.WriteFile(entryPath, []byte(`{"n":1}`), 0o600); err != nil {
2307 t.Fatal(err)
2308 }
2309
2310 buf := captureStdout(t)
2311 runLedgerAppend([]string{"-app", "titogo", "-account", apiTestAccount, "carbon", entryPath})
2312
2313 out := buf.String()
2314 if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) {
2315 t.Fatalf("stdout = %q, want it to contain %q", out, want)
2316 }
2317 if !strings.Contains(out, "appended to carbon") || !strings.Contains(out, "#7") {
2318 t.Fatalf("stdout = %q, want the appended line naming the ledger and count 7", out)
2319 }
2320 if !strings.Contains(out, strings.Repeat("c", 12)) {
2321 t.Fatalf("stdout = %q, want the sha's first 12 hex chars", out)
2322 }
2323 if got := atomic.LoadInt32(&reqCount); got != 1 {
2324 t.Fatalf("server got %d requests, want 1", got)
2325 }
2326 checkNoRegistry()
2327}
2328
2329// TestLedgerAppendVerbReadsStdin pins the "-" convention: the entry argument
2330// "-" reads the payload from stdin rather than treating "-" as a filename.
2331func TestLedgerAppendVerbReadsStdin(t *testing.T) {
2332 useTempCredentials(t)
2333 clearDeploymentEnv(t)
2334
2335 var gotPayload string
2336 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2337 var in struct {
2338 Payload json.RawMessage `json:"payload"`
2339 }
2340 json.NewDecoder(r.Body).Decode(&in)
2341 gotPayload = string(in.Payload)
2342 json.NewEncoder(w).Encode(ledger.Appended{Key: "k", SHA256: strings.Repeat("d", 64), Count: 1})
2343 }))
2344 defer ts.Close()
2345 loginTo(t, ts.URL, "vicky@keymail.dev")
2346
2347 r, w, err := os.Pipe()
2348 if err != nil {
2349 t.Fatal(err)
2350 }
2351 if _, err := w.WriteString(`{"n":2}`); err != nil {
2352 t.Fatal(err)
2353 }
2354 w.Close()
2355 origStdin := os.Stdin
2356 os.Stdin = r
2357 t.Cleanup(func() { os.Stdin = origStdin })
2358
2359 buf := captureStdout(t)
2360 runLedgerAppend([]string{"-app", "titogo", "-account", apiTestAccount, "carbon", "-"})
2361
2362 if gotPayload != `{"n":2}` {
2363 t.Fatalf("payload the server received = %q, want the stdin content", gotPayload)
2364 }
2365 if !strings.Contains(buf.String(), "appended to carbon") {
2366 t.Fatalf("stdout = %q", buf.String())
2367 }
2368}
2369
2370// TestLedgerHeadVerbAPIMode pins the one deliberately-different output shape
2371// in this file: head prints ONLY the HEAD JSON (no "using ... as ..."
2372// banner line) so a script can pipe it straight into `jq`.
2373func TestLedgerHeadVerbAPIMode(t *testing.T) {
2374 useTempCredentials(t)
2375 clearDeploymentEnv(t)
2376
2377 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2378 if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers/carbon/head" {
2379 t.Errorf("path = %s", r.URL.Path)
2380 }
2381 json.NewEncoder(w).Encode(ledger.Head{
2382 Schema: 1, UpdatedAt: "2026-08-03T00:00:00Z",
2383 HeadKey: "ledgers/x/HEAD.json", HeadSHA256: strings.Repeat("e", 64), Count: 9,
2384 })
2385 }))
2386 defer ts.Close()
2387 loginTo(t, ts.URL, "vicky@keymail.dev")
2388
2389 buf := captureStdout(t)
2390 runLedgerHead([]string{"-app", "titogo", "-account", apiTestAccount, "carbon"})
2391
2392 out := buf.String()
2393 if strings.Contains(out, "using ") {
2394 t.Fatalf("stdout = %q, head must print ONLY the HEAD JSON, no banner line", out)
2395 }
2396 var h ledger.Head
2397 if err := json.Unmarshal([]byte(out), &h); err != nil {
2398 t.Fatalf("head output is not valid JSON: %v (%q)", err, out)
2399 }
2400 if h.Count != 9 {
2401 t.Fatalf("head.Count = %d, want 9", h.Count)
2402 }
2403}
2404
2405// Note: runLedgerHead's 404 path ends in log.Fatal(err), which exits the
2406// process — untestable in-process, matching this file's existing convention
2407// (captureStdout's own doc comment above: "log.Fatal paths are untouched by
2408// this seam on purpose"). The error text itself, which is what a human
2409// actually reads, is pinned at the apiSession layer instead, by
2410// TestAPISessionLedgerHeadNotFound above.
2411
2412func TestLedgerListVerbAPIMode(t *testing.T) {
2413 useTempCredentials(t)
2414 clearDeploymentEnv(t)
2415
2416 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2417 if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers" {
2418 t.Errorf("path = %s", r.URL.Path)
2419 }
2420 json.NewEncoder(w).Encode(map[string]any{
2421 "manifest": map[string]any{"schema": 1, "public": true, "ledgers": []string{"carbon"}, "title": "Titogo"},
2422 "ledgers": []string{"carbon", "prompts"},
2423 })
2424 }))
2425 defer ts.Close()
2426 loginTo(t, ts.URL, "vicky@keymail.dev")
2427
2428 buf := captureStdout(t)
2429 runLedgerList([]string{"-app", "titogo", "-account", apiTestAccount})
2430
2431 out := buf.String()
2432 if !strings.Contains(out, `published "Titogo"`) || !strings.Contains(out, "carbon") {
2433 t.Fatalf("stdout = %q, want the published-manifest line naming the title and ledgers", out)
2434 }
2435 if !strings.Contains(out, "prompts") {
2436 t.Fatalf("stdout = %q, want the prompts ledger listed even though it isn't in the manifest", out)
2437 }
2438}
2439
2440// TestLedgerListVerbUnpublished pins the null-manifest case: "never
2441// published" must read as its own message, not an empty/zero-value table.
2442func TestLedgerListVerbUnpublished(t *testing.T) {
2443 useTempCredentials(t)
2444 clearDeploymentEnv(t)
2445
2446 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2447 json.NewEncoder(w).Encode(map[string]any{"manifest": nil, "ledgers": []string{"carbon"}})
2448 }))
2449 defer ts.Close()
2450 loginTo(t, ts.URL, "vicky@keymail.dev")
2451
2452 buf := captureStdout(t)
2453 runLedgerList([]string{"-app", "titogo", "-account", apiTestAccount})
2454
2455 out := buf.String()
2456 if !strings.Contains(out, "not published") {
2457 t.Fatalf("stdout = %q, want a not-published message for a null manifest", out)
2458 }
2459 if !strings.Contains(out, "carbon") {
2460 t.Fatalf("stdout = %q, want the carbon ledger still listed", out)
2461 }
2462}
2463
2464func TestLedgerPublishVerbAPIMode(t *testing.T) {
2465 useTempCredentials(t)
2466 clearDeploymentEnv(t)
2467
2468 var gotPath, gotBody string
2469 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2470 gotPath = r.URL.Path
2471 b, _ := io.ReadAll(r.Body)
2472 gotBody = string(b)
2473 // The server's answer names a ledger this call never mentioned:
2474 // publish is additive, so the resulting set is bigger than the ask.
2475 json.NewEncoder(w).Encode(ledger.Manifest{
2476 Schema: 1, Public: true, Ledgers: []string{"prompts", "carbon"}, Title: "Titogo"})
2477 }))
2478 defer ts.Close()
2479 loginTo(t, ts.URL, "vicky@keymail.dev")
2480
2481 buf := captureStdout(t)
2482 runLedgerPublish([]string{"-app", "titogo", "-account", apiTestAccount, "-title", "Titogo", "carbon"})
2483
2484 if gotPath != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers/publish" {
2485 t.Fatalf("path = %q", gotPath)
2486 }
2487 if !strings.Contains(gotBody, `"public":true`) || !strings.Contains(gotBody, `"carbon"`) || !strings.Contains(gotBody, `"Titogo"`) {
2488 t.Fatalf("publish body = %q", gotBody)
2489 }
2490 // The line reports the RESULTING public set, not the request's argument
2491 // — printing "carbon now public" while prompts is also public is how a
2492 // caller ends up believing they unpublished something they didn't.
2493 if !strings.Contains(buf.String(), "published titogo: public ledgers now [prompts carbon]") {
2494 t.Fatalf("stdout = %q, want the resulting public set", buf.String())
2495 }
2496}
2497
2498// TestLedgerPublishVerbUnpublish pins -public=false's own message (an
2499// unpublish is not the same act as a publish, and must not read like one),
2500// and that it too reports what is left public rather than what was removed.
2501func TestLedgerPublishVerbUnpublish(t *testing.T) {
2502 useTempCredentials(t)
2503 clearDeploymentEnv(t)
2504
2505 var gotBody string
2506 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2507 b, _ := io.ReadAll(r.Body)
2508 gotBody = string(b)
2509 json.NewEncoder(w).Encode(ledger.Manifest{
2510 Schema: 1, Public: true, Ledgers: []string{"prompts"}, Title: "Titogo"})
2511 }))
2512 defer ts.Close()
2513 loginTo(t, ts.URL, "vicky@keymail.dev")
2514
2515 buf := captureStdout(t)
2516 runLedgerPublish([]string{"-app", "titogo", "-account", apiTestAccount, "-public=false", "carbon"})
2517
2518 if !strings.Contains(gotBody, `"public":false`) {
2519 t.Fatalf("publish body = %q, want public:false", gotBody)
2520 }
2521 if !strings.Contains(buf.String(), "unpublished titogo: public ledgers now [prompts]") {
2522 t.Fatalf("stdout = %q, want what is still public", buf.String())
2523 }
2524}
2525
2526// TestLedgerPublishVerbUnpublishLast: when the last ledger goes, the line
2527// says so plainly rather than printing an empty bracket.
2528func TestLedgerPublishVerbUnpublishLast(t *testing.T) {
2529 useTempCredentials(t)
2530 clearDeploymentEnv(t)
2531
2532 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2533 json.NewEncoder(w).Encode(ledger.Manifest{Schema: 1, Public: false, Title: "Titogo"})
2534 }))
2535 defer ts.Close()
2536 loginTo(t, ts.URL, "vicky@keymail.dev")
2537
2538 buf := captureStdout(t)
2539 runLedgerPublish([]string{"-app", "titogo", "-account", apiTestAccount, "-public=false", "carbon"})
2540
2541 if !strings.Contains(buf.String(), "unpublished titogo: no public ledgers") {
2542 t.Fatalf("stdout = %q, want the emptied-set message", buf.String())
2543 }
2544}
2545
2546func TestLedgerBlobVerbAPIMode(t *testing.T) {
2547 useTempCredentials(t)
2548 clearDeploymentEnv(t)
2549
2550 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2551 if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers/prompts/blobs" {
2552 t.Errorf("path = %s", r.URL.Path)
2553 }
2554 b, _ := io.ReadAll(r.Body)
2555 json.NewEncoder(w).Encode(map[string]string{"sha256": ledger.SHA256Hex(b)})
2556 }))
2557 defer ts.Close()
2558 loginTo(t, ts.URL, "vicky@keymail.dev")
2559
2560 dir := t.TempDir()
2561 path := filepath.Join(dir, "transcript.txt")
2562 if err := os.WriteFile(path, []byte("corpus bytes"), 0o600); err != nil {
2563 t.Fatal(err)
2564 }
2565
2566 buf := captureStdout(t)
2567 runLedgerBlob([]string{"-app", "titogo", "-account", apiTestAccount, "prompts", path})
2568
2569 want := ledger.SHA256Hex([]byte("corpus bytes"))
2570 if !strings.Contains(buf.String(), want) {
2571 t.Fatalf("stdout = %q, want it to contain the uploaded blob's sha256 %q", buf.String(), want)
2572 }
2573}
2574
2575// TestLedgerBlobVerbMultipleFiles pins that "blob <ledger> <file>..." takes
2576// every trailing file argument, one upload per file.
2577func TestLedgerBlobVerbMultipleFiles(t *testing.T) {
2578 useTempCredentials(t)
2579 clearDeploymentEnv(t)
2580
2581 var reqCount int32
2582 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2583 atomic.AddInt32(&reqCount, 1)
2584 b, _ := io.ReadAll(r.Body)
2585 json.NewEncoder(w).Encode(map[string]string{"sha256": ledger.SHA256Hex(b)})
2586 }))
2587 defer ts.Close()
2588 loginTo(t, ts.URL, "vicky@keymail.dev")
2589
2590 dir := t.TempDir()
2591 pathA := filepath.Join(dir, "a.txt")
2592 pathB := filepath.Join(dir, "b.txt")
2593 if err := os.WriteFile(pathA, []byte("aaa"), 0o600); err != nil {
2594 t.Fatal(err)
2595 }
2596 if err := os.WriteFile(pathB, []byte("bbb"), 0o600); err != nil {
2597 t.Fatal(err)
2598 }
2599
2600 buf := captureStdout(t)
2601 runLedgerBlob([]string{"-app", "titogo", "-account", apiTestAccount, "prompts", pathA, pathB})
2602
2603 out := buf.String()
2604 if !strings.Contains(out, ledger.SHA256Hex([]byte("aaa"))) || !strings.Contains(out, ledger.SHA256Hex([]byte("bbb"))) {
2605 t.Fatalf("stdout = %q, want both files' sha256", out)
2606 }
2607 if got := atomic.LoadInt32(&reqCount); got != 2 {
2608 t.Fatalf("server got %d requests, want 2 (one per file)", got)
2609 }
2610}
2611
2612// TestLedgerVerifyVerb pins runLedgerVerify's own wiring: -url trimmed and
2613// handed to ledger.HTTPGetter, -account treated as a bare sqid (never
2614// resolved), and the printed message. Deliberately does NOT call
2615// useTempCredentials/clearDeploymentEnv/loginTo — the whole point of
2616// "verify -url" is that it needs no credential and no console session at
2617// all, and this test proves it by never configuring either.
2618func TestLedgerVerifyVerb(t *testing.T) {
2619 dir := t.TempDir()
2620 st := release.NewDirStore(dir)
2621 fixedNow := func(i int) func() time.Time {
2622 return func() time.Time { return time.Date(2026, 8, 3, 0, i, 0, 0, time.UTC) }
2623 }
2624 for i := range 2 {
2625 if _, err := ledger.Append(context.Background(), st, "bab", "platform", "carbon", "w",
2626 json.RawMessage(`{"i":1}`), fixedNow(i)); err != nil {
2627 t.Fatal(err)
2628 }
2629 }
2630
2631 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2632 b, _, err := st.Get(r.Context(), strings.TrimPrefix(r.URL.Path, "/"))
2633 if err != nil {
2634 http.NotFound(w, r)
2635 return
2636 }
2637 w.Write(b)
2638 }))
2639 defer ts.Close()
2640
2641 buf := captureStdout(t)
2642 // Trailing slash on -url, deliberately, to pin strings.TrimRight.
2643 runLedgerVerify([]string{"-app", "platform", "-account", "bab", "-url", ts.URL + "/", "carbon"})
2644
2645 if !strings.Contains(buf.String(), "verified: 2 entries") {
2646 t.Fatalf("stdout = %q, want the verified line", buf.String())
2647 }
2648}
2649
2650// ---- Task 3: config client methods (configGet/configApply/configSync) ----
2651
2652// TestAPISessionConfigGetFullShape pins configGet's decode of the full wire
2653// shape GET /api/cli/apps/{account}/{app}/config sends (spec §2): bundle,
2654// exec_delivery, secrets_pubkey, environments, and vars of both kinds — a
2655// secret row carries no value on the wire, and apiConfigVar.Value decodes to
2656// "" for it exactly as it would for an env var that happened to be empty.
2657func TestAPISessionConfigGetFullShape(t *testing.T) {
2658 var gotPath string
2659 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2660 gotPath = r.URL.Path
2661 json.NewEncoder(w).Encode(map[string]any{
2662 "bundle": "default",
2663 "exec_delivery": "file",
2664 "secrets_pubkey": "abcdef==",
2665 "secrets_account": "bab",
2666 "environments": []string{"staging"},
2667 "vars": []map[string]any{
2668 {"key": "PORT", "kind": "env", "value": "8080"},
2669 {"key": "BANK_KEY", "kind": "secret"},
2670 },
2671 })
2672 }))
2673 defer ts.Close()
2674
2675 s := newTestSession(ts.URL)
2676 cfg, err := s.configGet(context.Background(), apiAcct{Seg: "acct"}, "app", "")
2677 if err != nil {
2678 t.Fatalf("configGet: %v", err)
2679 }
2680 if gotPath != "/api/cli/apps/acct/app/config" {
2681 t.Fatalf("path = %q", gotPath)
2682 }
2683 if cfg.Bundle != "default" || cfg.ExecDelivery != "file" || cfg.SecretsPubkey != "abcdef==" || cfg.SecretsAccount != "bab" {
2684 t.Fatalf("config = %+v", cfg)
2685 }
2686 if len(cfg.Environments) != 1 || cfg.Environments[0] != "staging" {
2687 t.Fatalf("environments = %v, want [staging]", cfg.Environments)
2688 }
2689 if len(cfg.Vars) != 2 {
2690 t.Fatalf("vars = %+v, want 2 rows", cfg.Vars)
2691 }
2692 if cfg.Vars[0] != (apiConfigVar{Key: "PORT", Kind: "env", Value: "8080"}) {
2693 t.Fatalf("vars[0] = %+v", cfg.Vars[0])
2694 }
2695 if cfg.Vars[1] != (apiConfigVar{Key: "BANK_KEY", Kind: "secret", Value: ""}) {
2696 t.Fatalf("vars[1] = %+v, want a secret row with no value", cfg.Vars[1])
2697 }
2698}
2699
2700// TestAPISessionConfigGetAbsentFieldsDecodeEmpty pins the omitted-field
2701// case: an empty app's response (no exec_delivery, no secrets_pubkey, per
2702// handleAPIConfigGet's own doc comment) must decode to "" for both, not
2703// error and not panic on a missing key.
2704func TestAPISessionConfigGetAbsentFieldsDecodeEmpty(t *testing.T) {
2705 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2706 json.NewEncoder(w).Encode(map[string]any{
2707 "bundle": "default",
2708 "environments": []string{},
2709 "vars": []any{},
2710 })
2711 }))
2712 defer ts.Close()
2713
2714 s := newTestSession(ts.URL)
2715 cfg, err := s.configGet(context.Background(), apiAcct{Seg: "acct"}, "app", "")
2716 if err != nil {
2717 t.Fatalf("configGet: %v", err)
2718 }
2719 if cfg.ExecDelivery != "" {
2720 t.Fatalf("ExecDelivery = %q, want \"\" for an omitted field", cfg.ExecDelivery)
2721 }
2722 if cfg.SecretsPubkey != "" {
2723 t.Fatalf("SecretsPubkey = %q, want \"\" for an omitted field", cfg.SecretsPubkey)
2724 }
2725 // An older console names no account; the CLI then seals unbound.
2726 if cfg.SecretsAccount != "" {
2727 t.Fatalf("SecretsAccount = %q, want \"\" for an omitted field", cfg.SecretsAccount)
2728 }
2729 if len(cfg.Vars) != 0 {
2730 t.Fatalf("Vars = %+v, want none", cfg.Vars)
2731 }
2732}
2733
2734// TestAPISessionConfigGetEnvironmentQueryParam pins the query-param rule:
2735// environment=="" sends NO ?environment= at all (the server's own zero
2736// value for "default bundle" is an absent parameter, not one carrying ""),
2737// while a named environment sends it verbatim.
2738func TestAPISessionConfigGetEnvironmentQueryParam(t *testing.T) {
2739 var gotRawQuery string
2740 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2741 gotRawQuery = r.URL.RawQuery
2742 json.NewEncoder(w).Encode(map[string]any{"bundle": "default", "environments": []string{}, "vars": []any{}})
2743 }))
2744 defer ts.Close()
2745
2746 s := newTestSession(ts.URL)
2747 if _, err := s.configGet(context.Background(), apiAcct{Seg: "acct"}, "app", ""); err != nil {
2748 t.Fatalf("configGet: %v", err)
2749 }
2750 if gotRawQuery != "" {
2751 t.Fatalf("query for environment=\"\" = %q, want no query at all", gotRawQuery)
2752 }
2753
2754 if _, err := s.configGet(context.Background(), apiAcct{Seg: "acct"}, "app", "staging"); err != nil {
2755 t.Fatalf("configGet: %v", err)
2756 }
2757 if gotRawQuery != "environment=staging" {
2758 t.Fatalf("query for environment=\"staging\" = %q, want environment=staging", gotRawQuery)
2759 }
2760}
2761
2762// TestAPISessionConfigGetExactAndEnvironment pins the COMBINATION, which
2763// neither single-parameter test covers: apiPath already appended
2764// ?exact=sqid for an -account-id call, so configGet's own append has to
2765// pick "&" instead of assuming "?" — get that wrong and the environment
2766// name is swallowed into the exact value, silently reading the DEFAULT
2767// bundle while the console name-matches a segment it was told not to.
2768func TestAPISessionConfigGetExactAndEnvironment(t *testing.T) {
2769 var gotRawQuery, gotExact, gotEnvironment, gotPath string
2770 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2771 gotRawQuery, gotPath = r.URL.RawQuery, r.URL.Path
2772 gotExact = r.URL.Query().Get("exact")
2773 gotEnvironment = r.URL.Query().Get("environment")
2774 json.NewEncoder(w).Encode(map[string]any{"bundle": "staging", "environments": []string{}, "vars": []any{}})
2775 }))
2776 defer ts.Close()
2777
2778 s := newTestSession(ts.URL)
2779 if _, err := s.configGet(context.Background(), apiAcct{Seg: "bah", Exact: true}, "app", "staging"); err != nil {
2780 t.Fatalf("configGet: %v", err)
2781 }
2782 if want := "exact=sqid&environment=staging"; gotRawQuery != want {
2783 t.Fatalf("query = %q, want %q", gotRawQuery, want)
2784 }
2785 // The server's own view: both parameters parse as their own values,
2786 // which is the property the separator choice actually protects.
2787 if gotExact != "sqid" || gotEnvironment != "staging" {
2788 t.Fatalf("server saw exact=%q environment=%q, want exact=sqid environment=staging", gotExact, gotEnvironment)
2789 }
2790 if want := "/api/cli/apps/bah/app/config"; gotPath != want {
2791 t.Fatalf("path = %q, want %q", gotPath, want)
2792 }
2793}
2794
2795// TestEnvListVerbAPIModeAccountIDAndEnvironment is
2796// TestPromoteVerbAPIModeAccountID's shape for the config surface: -account-id
2797// must reach the wire from an env/secrets verb too — sqid in the segment,
2798// ?exact=sqid alongside, and the colliding -account name never sent — with a
2799// -environment in play at the same time, which is the config verbs' own
2800// wrinkle.
2801func TestEnvListVerbAPIModeAccountIDAndEnvironment(t *testing.T) {
2802 useTempCredentials(t)
2803 clearDeploymentEnv(t)
2804 checkNoRegistry := requireNoRegistryTouch(t)
2805
2806 var reqCount int32
2807 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2808 atomic.AddInt32(&reqCount, 1)
2809 if want := "/api/cli/apps/bah/titogo/config"; r.URL.Path != want {
2810 t.Errorf("path = %s, want %s (the --account-id sqid as the segment)", r.URL.Path, want)
2811 }
2812 if got := r.URL.Query().Get("exact"); got != "sqid" {
2813 t.Errorf("exact = %q, want %q — --account-id must never be name-matched", got, "sqid")
2814 }
2815 if got := r.URL.Query().Get("environment"); got != "staging" {
2816 t.Errorf("environment = %q, want %q", got, "staging")
2817 }
2818 if strings.Contains(r.URL.String(), apiTestAccount) {
2819 t.Errorf("url %s carries the superseded --account name", r.URL.String())
2820 }
2821 json.NewEncoder(w).Encode(map[string]any{
2822 "bundle": "staging",
2823 "environments": []string{"staging"},
2824 "vars": []map[string]string{{"key": "PORT", "kind": "env", "value": "8080"}},
2825 })
2826 }))
2827 defer ts.Close()
2828
2829 loginTo(t, ts.URL, "vicky@keymail.dev")
2830
2831 // No captureStdout here: runEnvList's printing goes to os.Stdout
2832 // directly (printEnvList's tabwriter), not the swappable stdout var the
2833 // promote-family tests capture. The assertions that matter are the wire
2834 // ones above, inside the handler.
2835 runEnvList([]string{"-app", "titogo", "-environment", "staging", "-account", apiTestAccount, "-account-id", "bah"})
2836
2837 if got := atomic.LoadInt32(&reqCount); got != 1 {
2838 t.Fatalf("server got %d requests, want 1", got)
2839 }
2840 checkNoRegistry()
2841}
2842
2843// TestAPISessionConfigGetNotFound pins the gate's byte-identical 404 —
2844// http.NotFound's text/plain body, no JSON "error" field — reaching the
2845// same generic membership message every other client method here falls
2846// back to.
2847func TestAPISessionConfigGetNotFound(t *testing.T) {
2848 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2849 http.NotFound(w, r)
2850 }))
2851 defer ts.Close()
2852
2853 s := newTestSession(ts.URL)
2854 _, err := s.configGet(context.Background(), apiAcct{Seg: "nope"}, "app", "")
2855 if err == nil || !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") {
2856 t.Fatalf("configGet 404 error = %v, want the membership message", err)
2857 }
2858}
2859
2860// TestAPISessionConfigApplyEnvVarBody pins the exact wire body configApply
2861// sends for a plain env var set — appconfig.Var's OWN json tags produce
2862// {"kind":"env","value":"8080"} with no "envelope" key (omitempty), so no
2863// separate wire struct is needed in apimode.go.
2864func TestAPISessionConfigApplyEnvVarBody(t *testing.T) {
2865 var gotMethod, gotPath, gotBody string
2866 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2867 gotMethod = r.Method
2868 gotPath = r.URL.Path
2869 b, _ := io.ReadAll(r.Body)
2870 gotBody = string(b)
2871 json.NewEncoder(w).Encode(map[string]any{"result": "ok", "removed": 0})
2872 }))
2873 defer ts.Close()
2874
2875 s := newTestSession(ts.URL)
2876 _, err := s.configApply(context.Background(), apiAcct{Seg: "acct"}, "app", apiConfigApply{
2877 Environment: "",
2878 Set: map[string]appconfig.Var{"PORT": {Kind: "env", Value: "8080"}},
2879 })
2880 if err != nil {
2881 t.Fatalf("configApply: %v", err)
2882 }
2883 if gotMethod != http.MethodPut {
2884 t.Fatalf("method = %q, want PUT", gotMethod)
2885 }
2886 if gotPath != "/api/cli/apps/acct/app/config" {
2887 t.Fatalf("path = %q", gotPath)
2888 }
2889 if want := `{"environment":"","set":{"PORT":{"kind":"env","value":"8080"}}}`; gotBody != want {
2890 t.Fatalf("body = %q, want %q", gotBody, want)
2891 }
2892}
2893
2894// TestAPISessionConfigApplySecretEnvelopeBody pins the secret-set body: a
2895// sealed envelope, no "value" key (appconfig.Var's Value field omitempty).
2896func TestAPISessionConfigApplySecretEnvelopeBody(t *testing.T) {
2897 var gotBody string
2898 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2899 b, _ := io.ReadAll(r.Body)
2900 gotBody = string(b)
2901 json.NewEncoder(w).Encode(map[string]any{"result": "ok", "removed": 0})
2902 }))
2903 defer ts.Close()
2904
2905 s := newTestSession(ts.URL)
2906 _, err := s.configApply(context.Background(), apiAcct{Seg: "acct"}, "app", apiConfigApply{
2907 Environment: "",
2908 Set: map[string]appconfig.Var{"BANK_KEY": {Kind: "secret", Envelope: "sealedbytes=="}},
2909 })
2910 if err != nil {
2911 t.Fatalf("configApply: %v", err)
2912 }
2913 if want := `{"environment":"","set":{"BANK_KEY":{"kind":"secret","envelope":"sealedbytes=="}}}`; gotBody != want {
2914 t.Fatalf("body = %q, want %q", gotBody, want)
2915 }
2916}
2917
2918// TestAPISessionConfigApplyUnsetBody pins an unset-only request: no "set"
2919// key at all (map omitempty), "unset" carries the keys.
2920func TestAPISessionConfigApplyUnsetBody(t *testing.T) {
2921 var gotBody string
2922 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2923 b, _ := io.ReadAll(r.Body)
2924 gotBody = string(b)
2925 json.NewEncoder(w).Encode(map[string]any{"result": "ok", "removed": 1})
2926 }))
2927 defer ts.Close()
2928
2929 s := newTestSession(ts.URL)
2930 res, err := s.configApply(context.Background(), apiAcct{Seg: "acct"}, "app", apiConfigApply{
2931 Environment: "",
2932 Unset: []string{"OLD_KEY", "ALSO_MISSING"},
2933 })
2934 if err != nil {
2935 t.Fatalf("configApply: %v", err)
2936 }
2937 if want := `{"environment":"","unset":["OLD_KEY","ALSO_MISSING"]}`; gotBody != want {
2938 t.Fatalf("body = %q, want %q", gotBody, want)
2939 }
2940 // removed is the server's ACTUAL count, never the argument count — two
2941 // keys were asked for, only one was really removed.
2942 if res.Removed != 1 {
2943 t.Fatalf("removed = %d, want 1 (the server's count, not len(Unset)==2)", res.Removed)
2944 }
2945 // A response with no "restart" key at all leaves Restart nil — the
2946 // "console said nothing" state, which the CLI renders as silence rather
2947 // than as "nothing needs restarting".
2948 if res.Restart != nil {
2949 t.Fatalf("Restart = %+v, want nil for a response that omits the field", res.Restart)
2950 }
2951}
2952
2953// TestAPISessionConfigApplyExecDeliveryPresent pins the exec_delivery
2954// pointer's PRESENT case: the field appears on the wire with the exact
2955// string the pointer carries — "off" here, never "" (see runEnvExecDelivery
2956// in appconfig_cli.go — the CLI itself never sends "").
2957func TestAPISessionConfigApplyExecDeliveryPresent(t *testing.T) {
2958 var gotBody string
2959 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2960 b, _ := io.ReadAll(r.Body)
2961 gotBody = string(b)
2962 json.NewEncoder(w).Encode(map[string]any{"result": "ok", "removed": 0})
2963 }))
2964 defer ts.Close()
2965
2966 s := newTestSession(ts.URL)
2967 off := "off"
2968 if _, err := s.configApply(context.Background(), apiAcct{Seg: "acct"}, "app", apiConfigApply{ExecDelivery: &off}); err != nil {
2969 t.Fatalf("configApply: %v", err)
2970 }
2971 if want := `{"environment":"","exec_delivery":"off"}`; gotBody != want {
2972 t.Fatalf("body = %q, want %q", gotBody, want)
2973 }
2974}
2975
2976// TestAPISessionConfigApplyExecDeliveryAbsent pins the ABSENT case: a nil
2977// pointer omits "exec_delivery" from the wire entirely (omitempty on the
2978// pointer field) — "no change requested", distinct from an explicit "off".
2979func TestAPISessionConfigApplyExecDeliveryAbsent(t *testing.T) {
2980 var gotBody string
2981 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2982 b, _ := io.ReadAll(r.Body)
2983 gotBody = string(b)
2984 json.NewEncoder(w).Encode(map[string]any{"result": "ok", "removed": 0})
2985 }))
2986 defer ts.Close()
2987
2988 s := newTestSession(ts.URL)
2989 if _, err := s.configApply(context.Background(), apiAcct{Seg: "acct"}, "app",
2990 apiConfigApply{Set: map[string]appconfig.Var{"K": {Kind: "env", Value: "v"}}}); err != nil {
2991 t.Fatalf("configApply: %v", err)
2992 }
2993 if strings.Contains(gotBody, "exec_delivery") {
2994 t.Fatalf("body = %q, must not carry exec_delivery when the pointer is nil", gotBody)
2995 }
2996}
2997
2998// TestAPISessionConfigApplyNotFound pins the gate's generic 404 message,
2999// mirroring every other client method's fallback.
3000func TestAPISessionConfigApplyNotFound(t *testing.T) {
3001 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3002 http.NotFound(w, r)
3003 }))
3004 defer ts.Close()
3005
3006 s := newTestSession(ts.URL)
3007 _, err := s.configApply(context.Background(), apiAcct{Seg: "nope"}, "app", apiConfigApply{Unset: []string{"K"}})
3008 if err == nil || !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") {
3009 t.Fatalf("configApply 404 error = %v, want the membership message", err)
3010 }
3011}
3012
3013// TestAPISessionConfigApplyErrorBody pins a real JSON diagnosis (a CAS
3014// conflict, say) surfacing verbatim, not folded into the generic message.
3015func TestAPISessionConfigApplyErrorBody(t *testing.T) {
3016 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3017 w.WriteHeader(http.StatusConflict)
3018 json.NewEncoder(w).Encode(map[string]string{"error": "config changed concurrently — try again"})
3019 }))
3020 defer ts.Close()
3021
3022 s := newTestSession(ts.URL)
3023 _, err := s.configApply(context.Background(), apiAcct{Seg: "acct"}, "app", apiConfigApply{Unset: []string{"K"}})
3024 if err == nil || !strings.Contains(err.Error(), "config changed concurrently") {
3025 t.Fatalf("configApply 409 error = %v, want the server's own message", err)
3026 }
3027}
3028
3029// TestAPISessionConfigSync pins the sync door's method/path and success.
3030func TestAPISessionConfigSync(t *testing.T) {
3031 var gotMethod, gotPath string
3032 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3033 gotMethod = r.Method
3034 gotPath = r.URL.Path
3035 json.NewEncoder(w).Encode(map[string]string{"result": "requested"})
3036 }))
3037 defer ts.Close()
3038
3039 s := newTestSession(ts.URL)
3040 if err := s.configSync(context.Background(), apiAcct{Seg: "acct"}, "app"); err != nil {
3041 t.Fatalf("configSync: %v", err)
3042 }
3043 if gotMethod != http.MethodPost {
3044 t.Fatalf("method = %q, want POST", gotMethod)
3045 }
3046 if gotPath != "/api/cli/apps/acct/app/config/sync" {
3047 t.Fatalf("path = %q", gotPath)
3048 }
3049}
3050
3051// TestAPISessionConfigSyncError pins error passthrough on the sync door.
3052func TestAPISessionConfigSyncError(t *testing.T) {
3053 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3054 w.WriteHeader(http.StatusInternalServerError)
3055 json.NewEncoder(w).Encode(map[string]string{"error": "could not store the config — try again"})
3056 }))
3057 defer ts.Close()
3058
3059 s := newTestSession(ts.URL)
3060 err := s.configSync(context.Background(), apiAcct{Seg: "acct"}, "app")
3061 if err == nil || !strings.Contains(err.Error(), "could not store the config") {
3062 t.Fatalf("configSync error = %v, want the server's message", err)
3063 }
3064}
3065
3066// TestAPISessionCreateApp: the createApp client sends the claim to the
3067// literal `new` door with the name in the body, honours -account-id's
3068// exact=sqid transport, surfaces the server's own refusal sentence, and
3069// rewrites the bare 404 into create's account-only wording (there is no
3070// -app to second-guess on this verb).
3071func TestAPISessionCreateApp(t *testing.T) {
3072 var gotPath, gotQuery, gotName string
3073 status := http.StatusOK
3074 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3075 gotPath, gotQuery = r.URL.Path, r.URL.RawQuery
3076 var req struct {
3077 Name string `json:"name"`
3078 }
3079 json.NewDecoder(r.Body).Decode(&req)
3080 gotName = req.Name
3081 if status != http.StatusOK {
3082 w.WriteHeader(status)
3083 if status == http.StatusConflict {
3084 json.NewEncoder(w).Encode(map[string]string{"error": "that name is taken"})
3085 }
3086 return
3087 }
3088 json.NewEncoder(w).Encode(map[string]any{
3089 "account": "bae", "app": req.Name, "url": "https://console.example/a/bae/apps/" + req.Name,
3090 })
3091 }))
3092 defer ts.Close()
3093 s := newTestSession(ts.URL)
3094
3095 res, err := s.createApp(context.Background(), apiAcct{Seg: "keymail"}, "paul", "", false, "", "", "")
3096 if err != nil {
3097 t.Fatalf("createApp: %v", err)
3098 }
3099 if gotPath != "/api/cli/apps/keymail/new" || gotQuery != "" || gotName != "paul" {
3100 t.Fatalf("request = %s?%s name=%q", gotPath, gotQuery, gotName)
3101 }
3102 if res.Account != "bae" || res.App != "paul" || res.URL != "https://console.example/a/bae/apps/paul" {
3103 t.Fatalf("result = %+v", res)
3104 }
3105
3106 if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae", Exact: true}, "paul", "", false, "", "", ""); err != nil {
3107 t.Fatalf("createApp exact: %v", err)
3108 }
3109 if gotQuery != "exact=sqid" {
3110 t.Fatalf("exact query = %q, want exact=sqid", gotQuery)
3111 }
3112
3113 status = http.StatusConflict
3114 if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "paul", "", false, "", "", ""); err == nil ||
3115 err.Error() != "that name is taken" {
3116 t.Fatalf("409 err = %v, want the server's sentence", err)
3117 }
3118
3119 status = http.StatusNotFound
3120 if _, err := s.createApp(context.Background(), apiAcct{Seg: "nope"}, "paul", "", false, "", "", ""); err == nil ||
3121 !strings.Contains(err.Error(), "check --account,") {
3122 t.Fatalf("404 err = %v, want create's account-only wording", err)
3123 }
3124}
3125
3126// TestAPISessionCreateAppSendsPlace: the optional place argument (`carlos
3127// apps create -place fleet/<f>`, 2026-08-06 ruling) travels in the create
3128// body's "place" field, and an empty place is simply absent from the
3129// request semantics the server reads as "no place given" either way — this
3130// pins that the WIRE VALUE sent is exactly what was passed, not silently
3131// dropped or renamed.
3132func TestAPISessionCreateAppSendsPlace(t *testing.T) {
3133 var gotPlace string
3134 var sawPlaceKey bool
3135 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3136 var raw map[string]any
3137 body, _ := io.ReadAll(r.Body)
3138 json.Unmarshal(body, &raw)
3139 _, sawPlaceKey = raw["place"]
3140 var req struct {
3141 Name string `json:"name"`
3142 Place string `json:"place"`
3143 }
3144 json.Unmarshal(body, &req)
3145 gotPlace = req.Place
3146 json.NewEncoder(w).Encode(map[string]any{
3147 "account": "bae", "app": req.Name, "url": "https://console.example/a/bae/apps/" + req.Name,
3148 })
3149 }))
3150 defer ts.Close()
3151 s := newTestSession(ts.URL)
3152
3153 if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "titogo", "fleet/pi", false, "", "", ""); err != nil {
3154 t.Fatalf("createApp with place: %v", err)
3155 }
3156 if gotPlace != "fleet/pi" {
3157 t.Fatalf("wire place = %q, want fleet/pi", gotPlace)
3158 }
3159
3160 if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "unplaced", "", false, "", "", ""); err != nil {
3161 t.Fatalf("createApp with no place: %v", err)
3162 }
3163 if sawPlaceKey && gotPlace != "" {
3164 t.Fatalf("empty place must not travel as a non-empty value; got %q", gotPlace)
3165 }
3166}
3167
3168// accountsConsole is a console stub for the omitted--account resolution
3169// tests: it serves GET /api/cli/apps with the given status and body, and
3170// counts how often the endpoint is consulted.
3171func accountsConsole(t *testing.T, status int, body string) (*apiSession, *int) {
3172 t.Helper()
3173 hits := 0
3174 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3175 if r.URL.Path != "/api/cli/apps" || r.Method != http.MethodGet {
3176 t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
3177 http.NotFound(w, r)
3178 return
3179 }
3180 hits++
3181 w.WriteHeader(status)
3182 io.WriteString(w, body)
3183 }))
3184 t.Cleanup(ts.Close)
3185 return newTestSession(ts.URL), &hits
3186}
3187
3188func TestDefaultAccountSingleMembership(t *testing.T) {
3189 useTempCredentials(t)
3190 out := captureStdout(t)
3191 s, hits := accountsConsole(t, http.StatusOK,
3192 `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","role":"owner","apps":[]}]}`)
3193
3194 a, err := s.defaultAccount(context.Background())
3195 if err != nil || a.Seg != "bdk" || !a.Exact {
3196 t.Fatalf("one membership = %+v, %v; want its sqid, exact", a, err)
3197 }
3198 if *hits != 1 {
3199 t.Fatalf("endpoint hits = %d, want 1", *hits)
3200 }
3201 if !strings.Contains(out.String(), "account: tom@keymail.dev (bdk)") {
3202 t.Fatalf("the resolved account must be printed; got:\n%s", out.String())
3203 }
3204}
3205
3206// forceTerminal pins interactiveTerminal for one test — the real check
3207// reads process file descriptors, which a test controls in neither
3208// direction (under `go test` stdin is /dev/null, a char device).
3209func forceTerminal(t *testing.T, tty bool) {
3210 t.Helper()
3211 old := interactiveTerminal
3212 interactiveTerminal = func() bool { return tty }
3213 t.Cleanup(func() { interactiveTerminal = old })
3214}
3215
3216func TestDefaultAccountSeveralNonTTY(t *testing.T) {
3217 useTempCredentials(t)
3218 captureStdout(t)
3219 forceTerminal(t, false)
3220 s, _ := accountsConsole(t, http.StatusOK,
3221 `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev"},{"sqid":"bce","name":"acme"}]}`)
3222
3223 // No terminal: several memberships take the non-interactive refusal —
3224 // which names every account.
3225 _, err := s.defaultAccount(context.Background())
3226 want := "you belong to several accounts — say which with --account: tom@keymail.dev (bdk), acme (bce)"
3227 if err == nil || err.Error() != want {
3228 t.Fatalf("several = %v\nwant %q", err, want)
3229 }
3230}
3231
3232func TestDefaultAccountChooserPicksSavesAndMemoizes(t *testing.T) {
3233 useTempCredentials(t)
3234 out := captureStdout(t)
3235 forceTerminal(t, true)
3236 oldIn := chooserIn
3237 chooserIn = strings.NewReader("2\ny\n")
3238 t.Cleanup(func() { chooserIn = oldIn })
3239 s, hits := accountsConsole(t, http.StatusOK,
3240 `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev"},{"sqid":"bce","name":"acme"}]}`)
3241
3242 a, err := s.defaultAccount(context.Background())
3243 if err != nil || a.Seg != "bce" || !a.Exact {
3244 t.Fatalf("chooser pick = %+v, %v; want acme's sqid, exact", a, err)
3245 }
3246 // y saved the pair to ./.carlos/config (cwd is a temp dir here).
3247 cfg, err := loadConfig(filepath.Join(".carlos", "config"))
3248 if err != nil || cfg.Account != "acme" || cfg.Console != s.console {
3249 t.Fatalf("saved config = %+v, %v; want the acme/%s pair", cfg, err, s.console)
3250 }
3251 if !strings.Contains(out.String(), "saved: acme") {
3252 t.Fatalf("the save must be confirmed:\n%s", out.String())
3253 }
3254
3255 // Asking again is memoized: same answer, no second fetch, no second
3256 // chooser (the input reader is already drained — a re-prompt would
3257 // EOF into the refusal).
3258 a2, err := s.defaultAccount(context.Background())
3259 if err != nil || a2 != a {
3260 t.Fatalf("second resolution = %+v, %v; want the memoized first", a2, err)
3261 }
3262 if *hits != 1 {
3263 t.Fatalf("endpoint hits = %d, want 1 — resolution happens once per invocation", *hits)
3264 }
3265}
3266
3267func TestDefaultAccountChooserSaveFailureIsNotFatal(t *testing.T) {
3268 useTempCredentials(t)
3269 out := captureStdout(t)
3270 forceTerminal(t, true)
3271 oldIn := chooserIn
3272 chooserIn = strings.NewReader("1\ny\n")
3273 t.Cleanup(func() { chooserIn = oldIn })
3274 s, _ := accountsConsole(t, http.StatusOK,
3275 `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev"},{"sqid":"bce","name":"acme"}]}`)
3276
3277 // A read-only working directory: the config walk still reads fine
3278 // (nothing exists to read), but MkdirAll(".carlos") cannot create.
3279 cwd, err := os.Getwd()
3280 if err != nil {
3281 t.Fatal(err)
3282 }
3283 if err := os.Chmod(cwd, 0o555); err != nil {
3284 t.Fatal(err)
3285 }
3286 t.Cleanup(func() { os.Chmod(cwd, 0o755) })
3287 // The human answered; the account is known; the save is bookkeeping.
3288 // A read-only checkout must not turn the answer into a dead ship.
3289 a, err := s.defaultAccount(context.Background())
3290 if err != nil || a.Seg != "bdk" {
3291 t.Fatalf("pick with failing save = %+v, %v; want the pick to stand", a, err)
3292 }
3293 if !strings.Contains(out.String(), "not saved") {
3294 t.Fatalf("a failed save must be said out loud:\n%s", out.String())
3295 }
3296}
3297
3298func TestDefaultAccountOldConsoleFallsBackToOps(t *testing.T) {
3299 useTempCredentials(t)
3300 captureStdout(t)
3301 s, _ := accountsConsole(t, http.StatusNotFound, "404 page not found\n")
3302
3303 a, err := s.defaultAccount(context.Background())
3304 if err != nil || a.Seg != "ops" || a.Exact {
3305 t.Fatalf("old console = %+v, %v; want the pre-discovery ops default", a, err)
3306 }
3307}
3308
3309func TestDefaultAccountOpsFallbackIsSaidOutLoud(t *testing.T) {
3310 useTempCredentials(t)
3311 out := captureStdout(t)
3312 s, _ := accountsConsole(t, http.StatusNotFound, "404 page not found\n")
3313
3314 // The fallback must not be silent: an invisible ops followed by the
3315 // membership 404 is the three-failures-deep riddle #157 is about.
3316 if _, err := s.defaultAccount(context.Background()); err != nil {
3317 t.Fatal(err)
3318 }
3319 if !strings.Contains(out.String(), "account: ops") || !strings.Contains(out.String(), s.console) {
3320 t.Fatalf("the ops fallback must name itself and the console:\n%s", out.String())
3321 }
3322}
3323
3324func TestDefaultAccountZeroMemberships(t *testing.T) {
3325 useTempCredentials(t)
3326 captureStdout(t)
3327 s, _ := accountsConsole(t, http.StatusOK, `{"accounts":[]}`)
3328
3329 if _, err := s.defaultAccount(context.Background()); err == nil ||
3330 !strings.Contains(err.Error(), s.console) {
3331 t.Fatalf("zero memberships = %v, want an error naming the console", err)
3332 }
3333}
3334
3335func TestDefaultAccountAuthErrorSurfaces(t *testing.T) {
3336 useTempCredentials(t)
3337 captureStdout(t)
3338 s, _ := accountsConsole(t, http.StatusUnauthorized, `{"error":"token expired"}`)
3339
3340 // A 401 is an answer about the TOKEN, not about memberships — it must
3341 // surface, never turn into an ops fallback that then 404s confusingly.
3342 if _, err := s.defaultAccount(context.Background()); err == nil ||
3343 !strings.Contains(err.Error(), "token expired") {
3344 t.Fatalf("401 = %v, want the console's own diagnosis", err)
3345 }
3346}
3347
3348func TestDefaultAccountConfigDefault(t *testing.T) {
3349 useTempCredentials(t)
3350 out := captureStdout(t)
3351 s, hits := accountsConsole(t, http.StatusOK, `{"accounts":[]}`)
3352
3353 // A config account paired with THIS console is honored without asking
3354 // the console anything.
3355 cfgPath := writeConfig(t, ".", `{"console": "`+s.console+`", "account": "acme"}`)
3356 a, err := s.defaultAccount(context.Background())
3357 if err != nil || a.Seg != "acme" || a.Exact {
3358 t.Fatalf("config account = %+v, %v; want the name, resolved server-side", a, err)
3359 }
3360 if *hits != 0 {
3361 t.Fatalf("endpoint hits = %d, want 0 — config answered it", *hits)
3362 }
3363 if !strings.Contains(out.String(), "acme") || !strings.Contains(out.String(), cfgPath) {
3364 t.Fatalf("the config-sourced account and its file must be printed; got:\n%s", out.String())
3365 }
3366
3367 // A config account with NO console is ignored: the pair is one fact,
3368 // and an account key that applied to every console would let a
3369 // checked-in file redirect ships across stacks. With zero memberships
3370 // behind it, resolution reports those — proving the fall-through.
3371 // (Each check below uses a fresh session: resolution memoizes per
3372 // invocation, and these are separate invocations.)
3373 writeConfig(t, ".", `{"account": "acme"}`)
3374 s2 := newTestSession(s.console)
3375 if _, err = s2.defaultAccount(context.Background()); err == nil ||
3376 !strings.Contains(err.Error(), "no account") {
3377 t.Fatalf("consoleless config account = %v, want fall-through to memberships", err)
3378 }
3379 if *hits != 1 {
3380 t.Fatalf("endpoint hits = %d, want exactly the fall-through call", *hits)
3381 }
3382
3383 // Paired with a DIFFERENT console it is ignored the same way.
3384 writeConfig(t, ".", `{"console": "https://other.example", "account": "acme"}`)
3385 s3 := newTestSession(s.console)
3386 if _, err = s3.defaultAccount(context.Background()); err == nil ||
3387 !strings.Contains(err.Error(), "no account") {
3388 t.Fatalf("mismatched config console = %v, want fall-through to memberships", err)
3389 }
3390 if *hits != 2 {
3391 t.Fatalf("endpoint hits = %d, want one per fall-through", *hits)
3392 }
3393}
3394
3395func TestConsoleMovedDetection(t *testing.T) {
3396 from := "https://dashboard.example"
3397 // The real case: a permanent redirect to a new https origin.
3398 if e := consoleMoved(from, 308, "https://console.example/api/cli/apps"); e == nil ||
3399 e.To != "https://console.example" {
3400 t.Fatalf("308 = %+v, want console.example", e)
3401 }
3402 want := "this console has moved: https://dashboard.example -> https://console.example — run: carlos auth login --console https://console.example"
3403 if e := consoleMoved(from, 301, "https://console.example/"); e == nil || e.Error() != want {
3404 t.Fatalf("moved error = %v\nwant %q", e, want)
3405 }
3406 // Everything that must NOT read as "moved" — including TEMPORARY
3407 // redirects: 302/307 are what SSO walls answer with, and "log in to
3408 // accounts.google.com" is not a remedy.
3409 for name, tc := range map[string]struct {
3410 status int
3411 location string
3412 }{
3413 "non-redirect status": {404, "https://console.example"},
3414 "302 sso wall": {302, "https://accounts.google.example"},
3415 "307 temporary": {307, "https://console.example"},
3416 "no location": {308, ""},
3417 "relative location": {308, "/somewhere"},
3418 "same origin": {308, from + "/new-path"},
3419 "http off-box target": {308, "http://console.example"},
3420 "garbage location": {308, "::not a url::"},
3421 "schemeless authority": {308, "console.example/x"},
3422 } {
3423 if e := consoleMoved(from, tc.status, tc.location); e != nil {
3424 t.Errorf("%s = %+v, want nil", name, e)
3425 }
3426 }
3427}
3428
3429func TestRedirectsAreNeverFollowed(t *testing.T) {
3430 useTempCredentials(t)
3431 captureStdout(t)
3432 forceTerminal(t, false)
3433 // A "new" console that would answer if the client followed — its
3434 // handler must never run.
3435 followed := false
3436 target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3437 followed = true
3438 w.Write([]byte(`{}`))
3439 }))
3440 t.Cleanup(target.Close)
3441 old := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3442 w.Header().Set("Location", target.URL+r.URL.Path)
3443 w.WriteHeader(http.StatusPermanentRedirect)
3444 }))
3445 t.Cleanup(old.Close)
3446
3447 s := newTestSession(old.URL)
3448 // GET (the case Go's default policy would have silently followed with
3449 // Authorization stripped) and POST both surface the typed error.
3450 _, _, err := s.getJSON(context.Background(), "/api/cli/apps")
3451 if err == nil || !strings.Contains(err.Error(), "this console has moved: "+old.URL+" -> "+target.URL) {
3452 t.Fatalf("GET through a redirect = %v", err)
3453 }
3454 _, _, err = s.postJSON(context.Background(), "/api/cli/apps/a/b/promote", map[string]string{})
3455 if err == nil || !strings.Contains(err.Error(), "this console has moved") {
3456 t.Fatalf("POST through a redirect = %v", err)
3457 }
3458 if followed {
3459 t.Fatal("the client followed a redirect — Authorization would have crossed hosts")
3460 }
3461}
3462
3463// resetMovedOffer clears the once-per-process offer guard for one test.
3464func resetMovedOffer(t *testing.T) {
3465 t.Helper()
3466 consoleMovedOffered = false
3467 t.Cleanup(func() { consoleMovedOffered = false })
3468}
3469
3470func TestConsoleMovedOfferLogsInAndRepointsConfig(t *testing.T) {
3471 useTempCredentials(t)
3472 clearDeploymentEnv(t)
3473 out := captureStdout(t)
3474 forceTerminal(t, true)
3475 resetMovedOffer(t)
3476 fc := newFakeConsole(t) // the console's NEW home — real device flow
3477 old := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3478 w.Header().Set("Location", fc.ts.URL+r.URL.Path)
3479 w.WriteHeader(http.StatusPermanentRedirect)
3480 }))
3481 t.Cleanup(old.Close)
3482 cfgPath := writeConfig(t, ".", `{"console": "`+old.URL+`", "account": "acme"}`)
3483 oldIn := chooserIn
3484 chooserIn = strings.NewReader("y\ny\n") // log in? yes. repoint config? yes.
3485 t.Cleanup(func() { chooserIn = oldIn })
3486
3487 s := newTestSession(old.URL)
3488 _, _, err := s.getJSON(context.Background(), "/api/cli/apps")
3489 if err == nil || !strings.Contains(err.Error(), "re-run your command") {
3490 t.Fatalf("offer accepted = %v, want the re-run message", err)
3491 }
3492 // The device flow really ran against the new host…
3493 f, ferr := loadCredentials()
3494 if ferr != nil || f.find(fc.ts.URL) == nil {
3495 t.Fatalf("no credential for the new console after the offer: %v", ferr)
3496 }
3497 // …and the config was repointed, keeping the account key.
3498 cfg, cerr := loadConfig(cfgPath)
3499 if cerr != nil || cfg.Console != fc.ts.URL || cfg.Account != "acme" {
3500 t.Fatalf("config after repoint = %+v, %v; want new console + acme kept", cfg, cerr)
3501 }
3502 if !strings.Contains(out.String(), "this console has moved") {
3503 t.Fatalf("the move must be explained:\n%s", out.String())
3504 }
3505 if !strings.Contains(out.String(), "to point at "+fc.ts.URL) {
3506 t.Fatalf("the repoint prompt must name the destination:\n%s", out.String())
3507 }
3508
3509 // The offer already ran once this process: a second hit — a polling
3510 // verb's next tick — gets the typed error and NO prompt, even with
3511 // answers queued up.
3512 out.Reset()
3513 chooserIn = strings.NewReader("y\ny\n")
3514 _, _, err = s.getJSON(context.Background(), "/api/cli/apps")
3515 if err == nil || !strings.Contains(err.Error(), "this console has moved") {
3516 t.Fatalf("second hit = %v, want the plain typed error", err)
3517 }
3518 if strings.Contains(out.String(), "[y/N]") {
3519 t.Fatalf("a second prompt fired:\n%s", out.String())
3520 }
3521}
3522
3523// whoami's memberships line degrades silently even through a MOVED console
3524// — a bonus line must never turn into a prompt (the quietConsoleOffers
3525// suppression).
3526func TestWhoamiStaysSilentThroughAMovedConsole(t *testing.T) {
3527 useTempCredentials(t)
3528 clearDeploymentEnv(t)
3529 captureStdout(t)
3530 forceTerminal(t, true)
3531 resetMovedOffer(t)
3532 mux := http.NewServeMux()
3533 mux.HandleFunc("GET /auth/cli/whoami", func(w http.ResponseWriter, r *http.Request) {
3534 json.NewEncoder(w).Encode(map[string]any{"address": "tom@keymail.dev"})
3535 })
3536 mux.HandleFunc("GET /api/cli/apps", func(w http.ResponseWriter, r *http.Request) {
3537 w.Header().Set("Location", "https://console.elsewhere.example/api/cli/apps")
3538 w.WriteHeader(http.StatusPermanentRedirect)
3539 })
3540 ts := httptest.NewServer(mux)
3541 t.Cleanup(ts.Close)
3542 f, _ := loadCredentials()
3543 f.put(credential{Console: ts.URL, Address: "tom@keymail.dev", Token: "tok",
3544 CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)})
3545 if err := f.save(); err != nil {
3546 t.Fatal(err)
3547 }
3548
3549 var out bytes.Buffer
3550 if err := authWhoami(&out, []string{"-console", ts.URL}); err != nil {
3551 t.Fatalf("whoami through a moved memberships endpoint: %v", err)
3552 }
3553 if strings.Contains(out.String(), "moved") || strings.Contains(out.String(), "[y/N]") ||
3554 strings.Contains(out.String(), "accounts:") {
3555 t.Fatalf("whoami must degrade to identity-only silence:\n%s", out.String())
3556 }
3557 if consoleMovedOffered {
3558 t.Fatal("whoami consumed the once-per-process offer")
3559 }
3560}
3561
3562// resolveMode on a terminal offers to run the login it would otherwise
3563// only name — and then the ORIGINAL command continues (#162).
3564func TestResolveModeOffersLoginAndContinues(t *testing.T) {
3565 useTempCredentials(t)
3566 clearDeploymentEnv(t)
3567 captureStdout(t)
3568 forceTerminal(t, true)
3569 fc := newFakeConsole(t)
3570 writeConfig(t, ".", `{"console": "`+fc.ts.URL+`"}`)
3571 oldIn := chooserIn
3572 chooserIn = strings.NewReader("y\n")
3573 t.Cleanup(func() { chooserIn = oldIn })
3574
3575 sess, err := resolveMode("")
3576 if err != nil || sess == nil {
3577 t.Fatalf("resolveMode after offered login = %v, %v", sess, err)
3578 }
3579 if sess.console != fc.ts.URL || sess.address != "paul@keymail.dev" {
3580 t.Fatalf("session = %s as %s, want the fake console as paul", sess.console, sess.address)
3581 }
3582}
3583
3584func TestResolveModeOfferDeclinedKeepsTheError(t *testing.T) {
3585 useTempCredentials(t)
3586 clearDeploymentEnv(t)
3587 captureStdout(t)
3588 forceTerminal(t, true)
3589 fc := newFakeConsole(t)
3590 writeConfig(t, ".", `{"console": "`+fc.ts.URL+`"}`)
3591 oldIn := chooserIn
3592 chooserIn = strings.NewReader("n\n")
3593 t.Cleanup(func() { chooserIn = oldIn })
3594
3595 _, err := resolveMode("")
3596 if err == nil || !strings.Contains(err.Error(), "not logged in to "+fc.ts.URL) ||
3597 !strings.Contains(err.Error(), "carlos auth login --console "+fc.ts.URL) {
3598 t.Fatalf("declined offer = %v, want the original refusal verbatim", err)
3599 }
3600}
3601
3602func TestResolveModeNonTTYErrorsUnchanged(t *testing.T) {
3603 useTempCredentials(t)
3604 clearDeploymentEnv(t)
3605 captureStdout(t)
3606 forceTerminal(t, false)
3607 fc := newFakeConsole(t)
3608 writeConfig(t, ".", `{"console": "`+fc.ts.URL+`"}`)
3609
3610 // No prompt machinery runs at all: scripts get the string they have
3611 // always matched.
3612 _, err := resolveMode("")
3613 if err == nil || !strings.Contains(err.Error(), "not logged in to "+fc.ts.URL) {
3614 t.Fatalf("non-TTY = %v, want today's refusal", err)
3615 }
3616
3617 // And the nothing-configured case keeps ITS exact wording too.
3618 os.RemoveAll(".carlos")
3619 if _, err := resolveMode(""); err == nil ||
3620 !strings.Contains(err.Error(), "no deployment target configured") {
3621 t.Fatalf("non-TTY unconfigured = %v", err)
3622 }
3623}
3624
3625func TestResolveModeExpiredOffersRelogin(t *testing.T) {
3626 useTempCredentials(t)
3627 clearDeploymentEnv(t)
3628 out := captureStdout(t)
3629 forceTerminal(t, true)
3630 fc := newFakeConsole(t)
3631 f, err := loadCredentials()
3632 if err != nil {
3633 t.Fatal(err)
3634 }
3635 f.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "stale",
3636 CreatedAt: time.Now().Add(-48 * time.Hour), ExpiresAt: time.Now().Add(-time.Hour)})
3637 if err := f.save(); err != nil {
3638 t.Fatal(err)
3639 }
3640 oldIn := chooserIn
3641 chooserIn = strings.NewReader("y\n")
3642 t.Cleanup(func() { chooserIn = oldIn })
3643
3644 // Only one credential and it is expired — pick's "" branch reaches the
3645 // expired-first rewrite, which must offer a re-login, and the fresh
3646 // credential must carry the NEW token.
3647 sess, err := resolveMode("")
3648 if err != nil || sess == nil || sess.console != fc.ts.URL {
3649 t.Fatalf("expired relogin = %v, %v", sess, err)
3650 }
3651 if !strings.Contains(out.String(), "has expired") {
3652 t.Fatalf("the expiry must be said before the offer:\n%s", out.String())
3653 }
3654}
3655
3656func TestResolveModeUnconfiguredPromptsForURL(t *testing.T) {
3657 useTempCredentials(t)
3658 clearDeploymentEnv(t)
3659 captureStdout(t)
3660 forceTerminal(t, true)
3661 fc := newFakeConsole(t)
3662 oldIn := chooserIn
3663 chooserIn = strings.NewReader(fc.ts.URL + "\n")
3664 t.Cleanup(func() { chooserIn = oldIn })
3665
3666 // An empty answer keeps the original two-remedies error. (Checked
3667 // FIRST — a successful login below leaves a credential behind, and
3668 // resolveMode would simply use it.)
3669 chooserIn = strings.NewReader("\n")
3670 if _, err := resolveMode(""); err == nil ||
3671 !strings.Contains(err.Error(), "no deployment target configured") {
3672 t.Fatalf("empty URL = %v", err)
3673 }
3674
3675 // Nothing configured anywhere: the URL prompt IS the consent — typing
3676 // one goes straight into the login flow.
3677 chooserIn = strings.NewReader(fc.ts.URL + "\n")
3678 sess, err := resolveMode("")
3679 if err != nil || sess == nil || sess.console != fc.ts.URL {
3680 t.Fatalf("URL-prompt login = %v, %v", sess, err)
3681 }
3682}
3683
3684func TestDefaultAppSingleSharesTheFetch(t *testing.T) {
3685 useTempCredentials(t)
3686 out := captureStdout(t)
3687 s, hits := accountsConsole(t, http.StatusOK,
3688 `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":["taskmonger"]}]}`)
3689
3690 // Account then app: one invocation, ONE fetch answering both.
3691 acct, err := s.defaultAccount(context.Background())
3692 if err != nil {
3693 t.Fatal(err)
3694 }
3695 app, err := s.defaultApp(context.Background(), acct, false)
3696 if err != nil || app != "taskmonger" {
3697 t.Fatalf("one app = %q, %v", app, err)
3698 }
3699 if *hits != 1 {
3700 t.Fatalf("endpoint hits = %d, want 1 shared fetch", *hits)
3701 }
3702 if !strings.Contains(out.String(), "app: taskmonger") {
3703 t.Fatalf("the resolved app must be printed:\n%s", out.String())
3704 }
3705}
3706
3707func TestDefaultAppSeveralNonTTY(t *testing.T) {
3708 useTempCredentials(t)
3709 captureStdout(t)
3710 forceTerminal(t, false)
3711 s, _ := accountsConsole(t, http.StatusOK,
3712 `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":["shop","blog"]}]}`)
3713
3714 _, err := s.defaultApp(context.Background(), apiAcct{Seg: "bdk", Exact: true}, false)
3715 want := "account tom@keymail.dev has several apps — say which with --app: shop, blog"
3716 if err == nil || err.Error() != want {
3717 t.Fatalf("several = %v\nwant %q", err, want)
3718 }
3719}
3720
3721func TestDefaultAppChooserSaves(t *testing.T) {
3722 useTempCredentials(t)
3723 captureStdout(t)
3724 forceTerminal(t, true)
3725 oldIn := chooserIn
3726 chooserIn = strings.NewReader("2\ny\n")
3727 t.Cleanup(func() { chooserIn = oldIn })
3728 s, _ := accountsConsole(t, http.StatusOK,
3729 `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":["shop","blog"]}]}`)
3730
3731 app, err := s.defaultApp(context.Background(), apiAcct{Seg: "bdk", Exact: true}, true)
3732 if err != nil || app != "blog" {
3733 t.Fatalf("chooser pick = %q, %v", app, err)
3734 }
3735 cfg, err := loadConfig(filepath.Join(".carlos", "config"))
3736 if err != nil || cfg.App != "blog" || cfg.Console != s.console || cfg.Account != "tom@keymail.dev" {
3737 t.Fatalf("saved config = %+v, %v; want console+account+app together", cfg, err)
3738 }
3739}
3740
3741func TestDefaultAppZeroIsTyped(t *testing.T) {
3742 useTempCredentials(t)
3743 captureStdout(t)
3744 s, _ := accountsConsole(t, http.StatusOK,
3745 `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":[]}]}`)
3746
3747 _, err := s.defaultApp(context.Background(), apiAcct{Seg: "bdk", Exact: true}, false)
3748 var na *noAppsError
3749 if err == nil || !errors.As(err, &na) || na.Account != "tom@keymail.dev" ||
3750 !strings.Contains(err.Error(), "carlos apps create") {
3751 t.Fatalf("zero apps = %v, want the typed no-apps refusal naming the fix", err)
3752 }
3753}
3754
3755func TestDefaultAppConfigPairing(t *testing.T) {
3756 useTempCredentials(t)
3757 captureStdout(t)
3758 s, hits := accountsConsole(t, http.StatusOK,
3759 `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":["shop","blog"]}]}`)
3760
3761 // Paired with THIS console: honored without any fetch.
3762 writeConfig(t, ".", `{"console": "`+s.console+`", "app": "shop"}`)
3763 app, err := s.defaultApp(context.Background(), apiAcct{Seg: "bdk", Exact: true}, false)
3764 if err != nil || app != "shop" || *hits != 0 {
3765 t.Fatalf("config app = %q, %v, hits=%d", app, err, *hits)
3766 }
3767
3768 // Paired with ANOTHER console: ignored — falls through to the account's
3769 // apps, which are ambiguous here, proving the fall-through.
3770 writeConfig(t, ".", `{"console": "https://other.example", "app": "shop"}`)
3771 forceTerminal(t, false)
3772 s2 := newTestSession(s.console)
3773 if _, err := s2.defaultApp(context.Background(), apiAcct{Seg: "bdk", Exact: true}, false); err == nil ||
3774 !strings.Contains(err.Error(), "several apps") {
3775 t.Fatalf("mismatched config console = %v, want fall-through", err)
3776 }
3777}
3778
3779func TestResolvedAccountExplicitFlagsBypass(t *testing.T) {
3780 useTempCredentials(t)
3781 captureStdout(t)
3782 s, hits := accountsConsole(t, http.StatusOK, `{"accounts":[]}`)
3783
3784 raw, rawID := "acme", ""
3785 if a := resolvedAccount(s, &raw, &rawID); a.Seg != "acme" || a.Exact {
3786 t.Fatalf("--account acme = %+v", a)
3787 }
3788 raw, rawID = "", "bdk"
3789 if a := resolvedAccount(s, &raw, &rawID); a.Seg != "bdk" || !a.Exact {
3790 t.Fatalf("--account-id bdk = %+v", a)
3791 }
3792 if *hits != 0 {
3793 t.Fatalf("explicit flags must never consult the endpoint; hits = %d", *hits)
3794 }
3795}
3796
3797// TestAPISessionInstanceCreateSendsKind pins the field the static mint
3798// depends on: the door has read a "kind" since the route-records design, but
3799// this client never sent one, so every record it wrote was an instance.
3800// Empty stays absent from the wire — the door's own "empty means instance"
3801// contract, not a value this client invents.
3802func TestAPISessionInstanceCreateSendsKind(t *testing.T) {
3803 for _, tc := range []struct {
3804 name string
3805 kind string
3806 wantKind any
3807 }{
3808 {"static", "static", "static"},
3809 {"empty is omitted", "", nil},
3810 } {
3811 t.Run(tc.name, func(t *testing.T) {
3812 var got map[string]any
3813 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3814 if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
3815 t.Errorf("decode body: %v", err)
3816 }
3817 w.WriteHeader(http.StatusCreated)
3818 io.WriteString(w, `{"host":"mysite.bab.oncarlos.test","kind":"static","channel":"edge"}`)
3819 }))
3820 defer ts.Close()
3821
3822 rec, err := newTestSession(ts.URL).instanceCreate(context.Background(),
3823 apiAcct{Seg: "bab"}, "mysite", "mysite.bab.oncarlos.test", "", "edge", tc.kind, "", nil, nil, nil)
3824 if err != nil {
3825 t.Fatalf("instanceCreate: %v", err)
3826 }
3827 if got["kind"] != tc.wantKind {
3828 t.Fatalf("wire kind = %#v, want %#v", got["kind"], tc.wantKind)
3829 }
3830 // The channel is the rail: an omitted one is defaulted to stable
3831 // by the door, and no door can change a record's channel after.
3832 if got["channel"] != "edge" {
3833 t.Fatalf("wire channel = %#v, want an explicit %q", got["channel"], "edge")
3834 }
3835 if rec.Kind != "static" {
3836 t.Fatalf("decoded Kind = %q", rec.Kind)
3837 }
3838 })
3839 }
3840}
3841
3842// TestAPISessionInstanceSetChannel pins the move client: the channel on the
3843// wire, the record back on a 200, and — the case that must not read as a
3844// failure — a 202 decoded as a PROPOSAL naming where to finish.
3845func TestAPISessionInstanceSetChannel(t *testing.T) {
3846 t.Run("lands", func(t *testing.T) {
3847 var got map[string]any
3848 var gotPath string
3849 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3850 gotPath = r.URL.Path
3851 if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
3852 t.Errorf("decode body: %v", err)
3853 }
3854 io.WriteString(w, `{"host":"web.bab.oncarlos.test","channel":"edge","kind":"instance"}`)
3855 }))
3856 defer ts.Close()
3857
3858 rec, err := newTestSession(ts.URL).instanceSetChannel(context.Background(),
3859 apiAcct{Seg: "bab"}, "mysite", "web.bab.oncarlos.test", "edge")
3860 if err != nil {
3861 t.Fatalf("instanceSetChannel: %v", err)
3862 }
3863 if got["channel"] != "edge" {
3864 t.Fatalf("wire channel = %#v", got["channel"])
3865 }
3866 if !strings.HasSuffix(gotPath, "/instances/web.bab.oncarlos.test/channel") {
3867 t.Fatalf("path = %q, want the per-host channel door", gotPath)
3868 }
3869 if rec.Channel != "edge" {
3870 t.Fatalf("decoded Channel = %q", rec.Channel)
3871 }
3872 })
3873
3874 t.Run("gated move is a proposal, not a failure", func(t *testing.T) {
3875 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3876 w.WriteHeader(http.StatusAccepted)
3877 io.WriteString(w, `{"result":"proposed","pending_id":"pc1","needed":2,"confirmed":0,`+
3878 `"console_url":"https://console.example/a/bab/apps/mysite"}`)
3879 }))
3880 defer ts.Close()
3881
3882 _, err := newTestSession(ts.URL).instanceSetChannel(context.Background(),
3883 apiAcct{Seg: "bab"}, "mysite", "web.bab.oncarlos.test", "edge")
3884 var prop *apiProposedError
3885 if !errors.As(err, &prop) {
3886 t.Fatalf("err = %v, want an *apiProposedError", err)
3887 }
3888 if prop.Needed != 2 || prop.ConsoleURL == "" {
3889 t.Fatalf("proposal = %+v, want the quorum and where to finish", prop)
3890 }
3891 })
3892}
3893
3894// sealSecretAPI binds to the account the console named, and seals the
3895// unbound shape only for a console that named none — an older door, which
3896// would refuse a bound envelope (discussion #220).
3897func TestSealSecretAPIBindsWhenTheConsoleNamesAnAccount(t *testing.T) {
3898 privB64, pubB64, err := appconfig.GenerateKey()
3899 if err != nil {
3900 t.Fatal(err)
3901 }
3902 priv, _ := appconfig.ParsePrivateKey(privB64, "test")
3903 pub, _ := appconfig.ParsePublicKey(pubB64, "test")
3904
3905 bound, err := sealSecretAPI(pub, "bab", "keymail", []byte("shh"))
3906 if err != nil {
3907 t.Fatal(err)
3908 }
3909 if _, err := appconfig.Open(priv, appconfig.Binding{Account: "bab", App: "keymail"}, bound); err != nil {
3910 t.Fatalf("bound envelope does not open for its own app: %v", err)
3911 }
3912 if _, err := appconfig.Open(priv, appconfig.Binding{Account: "zzz", App: "keymail"}, bound); err == nil {
3913 t.Fatal("envelope sealed with a named account opened under another account")
3914 }
3915
3916 unbound, err := sealSecretAPI(pub, "", "keymail", []byte("shh"))
3917 if err != nil {
3918 t.Fatal(err)
3919 }
3920 if err := appconfig.CheckEnvelope(unbound); err != nil {
3921 t.Fatal(err)
3922 }
3923 raw, _ := base64.StdEncoding.DecodeString(unbound)
3924 if raw[0] != 0x04 {
3925 t.Fatalf("fallback envelope is not the v1 shape an older door accepts (first byte %#x)", raw[0])
3926 }
3927}
3928
3929func TestResolveModeServiceToken(t *testing.T) {
3930 useIsolatedConfig(t)
3931 clearDeploymentEnv(t)
3932 var gotAuth string
3933 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3934 gotAuth = r.Header.Get("Authorization")
3935 w.Header().Set("Content-Type", "application/json")
3936 w.Write([]byte(`{"accounts":[]}`))
3937 }))
3938 defer ts.Close()
3939 // A person's credential for another console is on disk too. The token
3940 // wins and the file is not consulted.
3941 f, _ := loadCredentials()
3942 f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "person", ExpiresAt: time.Now().Add(time.Hour)})
3943 if err := f.save(); err != nil {
3944 t.Fatal(err)
3945 }
3946 t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret")
3947 t.Setenv("CARLOS_CONSOLE", ts.URL)
3948 sess, err := resolveMode("")
3949 if err != nil || sess == nil {
3950 t.Fatalf("resolveMode = %v, %v", sess, err)
3951 }
3952 if sess.address != serviceSessionLabel {
3953 t.Fatalf("address = %q", sess.address)
3954 }
3955 if _, _, err := sess.getJSON(context.Background(), "/api/cli/apps"); err != nil {
3956 t.Fatal(err)
3957 }
3958 if gotAuth != "Bearer svc-secret" {
3959 t.Fatalf("Authorization = %q, want the service token", gotAuth)
3960 }
3961}
3962
3963func TestResolveModeServiceTokenNeedsAConsole(t *testing.T) {
3964 useIsolatedConfig(t)
3965 clearDeploymentEnv(t)
3966 t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret")
3967 if _, err := resolveMode(""); err == nil || !strings.Contains(err.Error(), "CARLOS_CONSOLE") {
3968 t.Fatalf("err = %v, want D27", err)
3969 }
3970}
3971
3972func TestResolveModeServiceTokenRefusedWithStoreEnv(t *testing.T) {
3973 for _, env := range []string{"CARLOS_DEPLOYMENT_BUCKET", "CARLOS_DEPLOYMENT_DIR"} {
3974 t.Run(env, func(t *testing.T) {
3975 useIsolatedConfig(t)
3976 clearDeploymentEnv(t)
3977 t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret")
3978 t.Setenv("CARLOS_CONSOLE", "https://c.example")
3979 t.Setenv(env, t.TempDir())
3980 sess, err := resolveMode("")
3981 if err == nil || sess != nil || !strings.Contains(err.Error(), "CARLOS_SERVICE_TOKEN") {
3982 t.Fatalf("= %v, %v; want D28", sess, err)
3983 }
3984 })
3985 }
3986}
3987
3988// A bare store variable still selects direct mode silently when no token is
3989// set: the token arm changes nothing for anyone without one.
3990func TestResolveModeStoreEnvWithoutTokenStillDirect(t *testing.T) {
3991 useIsolatedConfig(t)
3992 clearDeploymentEnv(t)
3993 t.Setenv("CARLOS_DEPLOYMENT_DIR", t.TempDir())
3994 if sess, err := resolveMode(""); sess != nil || err != nil {
3995 t.Fatalf("= %v, %v; want (nil, nil)", sess, err)
3996 }
3997}
3998
3999// carlosDeployDoors is a COPY of spec §6's list of doors a deploy credential
4000// may reach (R6 included). The root package cannot import the console's test
4001// variable, so the source of truth is deployDoors in
4002// internal/console/deploydoors_test.go: change one, change the other.
4003var carlosDeployDoors = []string{
4004 "GET /api/cli/apps",
4005 "GET /api/cli/apps/{account}/{app}/channels",
4006 "POST /api/cli/apps/{account}/{app}/releases",
4007 "GET /api/cli/apps/{account}/{app}/releases",
4008 "POST /api/cli/apps/{account}/{app}/promote",
4009 "POST /api/cli/apps/{account}/{app}/rollback",
4010 "GET /api/cli/apps/{account}/{app}/tags",
4011 "GET /api/cli/apps/{account}/{app}/tags/{version}",
4012 "GET /api/cli/apps/{account}/{app}/version-target",
4013 "GET /api/cli/apps/{account}/{app}/pipeline",
4014 "GET /api/cli/apps/{account}/{app}/pending",
4015 "GET /api/cli/apps/{account}/{app}/instances",
4016 "GET /api/cli/apps/{account}/{app}/regions",
4017}
4018
4019// doorPattern maps a recorded "METHOD /path" back to the route pattern it
4020// was served by: the account and app segments become {account} and {app},
4021// and the segment after "tags" becomes {version}.
4022func doorPattern(method, path string) string {
4023 if i := strings.IndexByte(path, '?'); i >= 0 {
4024 path = path[:i]
4025 }
4026 seg := strings.Split(strings.Trim(path, "/"), "/")
4027 // api, cli, apps, account, app, tail...
4028 if len(seg) >= 5 {
4029 seg[3], seg[4] = "{account}", "{app}"
4030 if len(seg) == 7 && seg[5] == "tags" {
4031 seg[6] = "{version}"
4032 }
4033 }
4034 return method + " /" + strings.Join(seg, "/")
4035}
4036
4037func TestCarlosDeployRoutinePathUsesOnlyDeployDoors(t *testing.T) {
4038 var mu sync.Mutex
4039 var seen []string
4040 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
4041 mu.Lock()
4042 seen = append(seen, r.Method+" "+r.URL.Path)
4043 mu.Unlock()
4044 w.Header().Set("Content-Type", "application/json")
4045 if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/releases") {
4046 w.WriteHeader(http.StatusCreated)
4047 w.Write([]byte(`{"result":"shipped","version":"1.2.3","artifacts":{}}`))
4048 return
4049 }
4050 w.Write([]byte(`{"result":"promoted"}`))
4051 }))
4052 defer ts.Close()
4053 record := func() []string {
4054 mu.Lock()
4055 defer mu.Unlock()
4056 out := seen
4057 seen = nil
4058 return out
4059 }
4060
4061 file := filepath.Join(t.TempDir(), "a.bin")
4062 if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil {
4063 t.Fatal(err)
4064 }
4065 ctx := context.Background()
4066 s := newTestSession(ts.URL)
4067 a := apiAcct{Seg: "bab", Exact: true}
4068
4069 steps := []struct {
4070 name string
4071 run func() error
4072 }{
4073 {"ship", func() error {
4074 _, err := s.ship(ctx, a, "app", "1.2.3", "binary", "", "", map[string]string{"alpha-amd64": file}, nil, false)
4075 return err
4076 }},
4077 {"promote", func() error { _, err := s.promote(ctx, a, "app", "1.2.3", "canary", false, false); return err }},
4078 {"rollback", func() error { return s.rollback(ctx, a, "app", "canary") }},
4079 {"channels", func() error { _, err := s.channels(ctx, a, "app"); return err }},
4080 {"instanceList", func() error { _, _, _, err := s.instanceList(ctx, a, "app", "", ""); return err }},
4081 {"appRegions", func() error { _, err := s.appRegions(ctx, a, "app"); return err }},
4082 }
4083 allowed := map[string]bool{}
4084 for _, d := range carlosDeployDoors {
4085 allowed[d] = true
4086 }
4087 for _, st := range steps {
4088 if err := st.run(); err != nil {
4089 t.Fatalf("%s: %v", st.name, err)
4090 }
4091 reqs := record()
4092 if len(reqs) == 0 {
4093 t.Fatalf("%s sent no request", st.name)
4094 }
4095 for _, r := range reqs {
4096 method, path, _ := strings.Cut(r, " ")
4097 if p := doorPattern(method, path); !allowed[p] {
4098 t.Errorf("%s reached %q (%s), which a deploy credential is not given", st.name, r, p)
4099 }
4100 }
4101 }
4102
4103 // The watch's diagnosis reads the logs. That stays a best-effort read a
4104 // deploy credential is refused, so it must not be on the list.
4105 if _, err := s.logsGet(ctx, a, "app", nil); err != nil {
4106 t.Fatalf("logsGet: %v", err)
4107 }
4108 reqs := record()
4109 if len(reqs) != 1 {
4110 t.Fatalf("logsGet sent %v", reqs)
4111 }
4112 method, path, _ := strings.Cut(reqs[0], " ")
4113 if !strings.HasSuffix(path, "/logs") {
4114 t.Fatalf("logsGet path = %q, want .../logs", path)
4115 }
4116 if p := doorPattern(method, path); allowed[p] {
4117 t.Errorf("%q is on the deploy door list; the logs read must stay refused to a deploy credential", p)
4118 }
4119}
4120