// Tests for API mode resolution and the console API client the other CLI // verbs (Task 5) build on: which mode the CLI is in (direct bucket access or // through a console it holds a credential for), and the four calls a session // in API mode can make. package main import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "flag" "io" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/caps" "github.com/carlosframework/platform/internal/edge" "github.com/carlosframework/platform/internal/ledger" "github.com/carlosframework/platform/internal/release" ) // captureStdout swaps the package-level stdout var (cli.go — the seam the // four API-aware verbs print through) for a buffer, restoring it on cleanup. // log.Fatal paths are untouched by this seam on purpose — those still exit // the process, exactly as before. func captureStdout(t *testing.T) *bytes.Buffer { t.Helper() var buf bytes.Buffer orig := stdout stdout = &buf t.Cleanup(func() { stdout = orig }) return &buf } // clearDeploymentEnv ensures none of the three env vars resolveMode/pick // read leak in from the process running the test binary — resolveMode must // see a clean slate to make its own env check meaningful, and CARLOS_CONSOLE // left set would silently change which pick() branch a "" -console flag // takes. func clearDeploymentEnv(t *testing.T) { t.Helper() t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") t.Setenv("CARLOS_DEPLOYMENT_DIR", "") t.Setenv("CARLOS_CONSOLE", "") t.Setenv("CARLOS_SERVICE_TOKEN", "") } func TestResolveModeDirectWins(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "some-bucket") // A credential is present too — direct mode must win regardless, since a // box can be both logged in and configured for direct bucket access, and // existing direct-mode behaviour must not change out from under it. f, _ := loadCredentials() f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "t1", ExpiresAt: time.Now().Add(time.Hour)}) if err := f.save(); err != nil { t.Fatal(err) } sess, err := resolveMode("") if err != nil || sess != nil { t.Fatalf("resolveMode with CARLOS_DEPLOYMENT_BUCKET set = (%+v, %v), want (nil, nil)", sess, err) } } func TestResolveModeDirWins(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) t.Setenv("CARLOS_DEPLOYMENT_DIR", t.TempDir()) sess, err := resolveMode("") if err != nil || sess != nil { t.Fatalf("resolveMode with CARLOS_DEPLOYMENT_DIR set = (%+v, %v), want (nil, nil)", sess, err) } } func TestResolveModeAPI(t *testing.T) { useIsolatedConfig(t) clearDeploymentEnv(t) f, _ := loadCredentials() f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "t1", ExpiresAt: time.Now().Add(time.Hour)}) if err := f.save(); err != nil { t.Fatal(err) } sess, err := resolveMode("") if err != nil { t.Fatalf("resolveMode: %v", err) } if sess == nil { t.Fatal("resolveMode with one credential = nil session, want one") } if sess.console != "https://a.example" { t.Fatalf("session console = %q, want https://a.example", sess.console) } if sess.address != "a@keymail.dev" { t.Fatalf("session address = %q, want a@keymail.dev", sess.address) } if sess.c == nil { t.Fatal("session has no console client") } } func TestResolveModeNothing(t *testing.T) { useIsolatedConfig(t) clearDeploymentEnv(t) forceTerminal(t, false) // pin: this asserts the NON-interactive refusal sess, err := resolveMode("") if sess != nil { t.Fatalf("resolveMode with nothing configured = %+v, want nil session", sess) } if err == nil { t.Fatal("resolveMode with nothing configured must error") } if !strings.Contains(err.Error(), "CARLOS_DEPLOYMENT_BUCKET") { t.Fatalf("error %q does not mention CARLOS_DEPLOYMENT_BUCKET", err) } if !strings.Contains(err.Error(), "carlos auth login") { t.Fatalf("error %q does not mention carlos auth login", err) } } // TestResolveModeExpiredCredentialOnly pins the minor fix: a credential that // EXISTS but has expired is a different situation from nothing configured at // all, and deserves a message that says so instead of offering to set up // direct mode from scratch. func TestResolveModeExpiredCredentialOnly(t *testing.T) { useIsolatedConfig(t) clearDeploymentEnv(t) forceTerminal(t, false) // pin: this asserts the NON-interactive refusal f, _ := loadCredentials() f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "t1", ExpiresAt: time.Now().Add(-time.Hour)}) if err := f.save(); err != nil { t.Fatal(err) } sess, err := resolveMode("") if sess != nil { t.Fatalf("resolveMode with only an expired credential = %+v, want nil session", sess) } if err == nil { t.Fatal("resolveMode with only an expired credential must error") } if !strings.Contains(err.Error(), "https://a.example") { t.Fatalf("error %q does not name the expired console", err) } if !strings.Contains(err.Error(), "expired") { t.Fatalf("error %q does not say the credential expired", err) } if !strings.Contains(err.Error(), "carlos auth login") { t.Fatalf("error %q does not name the fix", err) } if strings.Contains(err.Error(), "CARLOS_DEPLOYMENT_BUCKET") { t.Fatalf("error %q wrongly offers direct mode for an expired (not absent) credential", err) } } // TestResolveModeConsoleFlagRefusedWithStoreEnv pins the design ruling from // the review round (2026-08-03): an explicit -console alongside a store env // is refused outright, rather than silently winning as direct mode with // -console quietly ignored — the spec's "the two modes never mix in one // invocation" intent (§1) would otherwise be violated invisibly. Both store // env vars are checked; a BARE env with no -console still wins silently, // unchanged (already pinned by TestResolveModeDirectWins/TestResolveModeDirWins, // both of which call resolveMode("")). func TestResolveModeConsoleFlagRefusedWithStoreEnv(t *testing.T) { for _, tc := range []struct { name string envVar string }{ {"bucket", "CARLOS_DEPLOYMENT_BUCKET"}, {"dir", "CARLOS_DEPLOYMENT_DIR"}, } { t.Run(tc.name, func(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) t.Setenv(tc.envVar, "something") sess, err := resolveMode("https://a.example") if sess != nil { t.Fatalf("resolveMode with --console AND %s set = %+v, want nil session", tc.envVar, sess) } if err == nil { t.Fatalf("resolveMode with --console AND %s set must error", tc.envVar) } if !strings.Contains(err.Error(), "-console") { t.Fatalf("error %q does not mention --console", err) } if !strings.Contains(err.Error(), tc.envVar) { t.Fatalf("error %q does not mention %s", err, tc.envVar) } }) } } func newTestSession(base string) *apiSession { return &apiSession{c: newConsoleClient(base, "cli-token"), console: base, address: "a@keymail.dev"} } // TestAPISessionShipMultipart pins the wire shape handleAPIShip // (internal/console/apicli.go) requires: bearer auth, the meta part first // with exact JSON, then artifact parts in map-key-sorted order (so the wire // bytes are deterministic instead of depending on map iteration order). func TestAPISessionShipMultipart(t *testing.T) { var gotAuth string var partNames []string var metaBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") mr, err := r.MultipartReader() if err != nil { t.Errorf("request was not multipart: %v", err) w.WriteHeader(http.StatusInternalServerError) return } for { part, err := mr.NextPart() if err == io.EOF { break } if err != nil { t.Errorf("reading part: %v", err) w.WriteHeader(http.StatusInternalServerError) return } b, err := io.ReadAll(part) if err != nil { t.Errorf("reading part body: %v", err) w.WriteHeader(http.StatusInternalServerError) return } if part.FormName() == "meta" { metaBody = string(b) } partNames = append(partNames, part.FormName()) } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]any{ "result": "shipped", "version": "1.2.3", "artifacts": map[string]any{}, }) })) defer ts.Close() dir := t.TempDir() fileA := filepath.Join(dir, "a.bin") fileB := filepath.Join(dir, "b.bin") if err := os.WriteFile(fileA, []byte("aaa"), 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(fileB, []byte("bbb"), 0o600); err != nil { t.Fatal(err) } s := newTestSession(ts.URL) res, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", map[string]string{ "zeta-amd64": fileB, "alpha-amd64": fileA, }, nil, false) if err != nil { t.Fatalf("ship: %v", err) } if res.NoOp { t.Fatal("a fresh (201) ship must not report NoOp") } if gotAuth != "Bearer cli-token" { t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer cli-token") } if want := `{"version":"1.2.3","kind":"binary"}`; metaBody != want { t.Fatalf("meta part = %q, want %q", metaBody, want) } wantOrder := []string{"meta", "alpha-amd64", "zeta-amd64"} if len(partNames) != len(wantOrder) { t.Fatalf("part names = %v, want %v", partNames, wantOrder) } for i := range wantOrder { if partNames[i] != wantOrder[i] { t.Fatalf("part order = %v, want %v", partNames, wantOrder) } } } // TestAPISessionShipNoOp pins handleAPIShip's identical-reship response // exactly as apicli.go sends it: still "result": "shipped" — 200 rather // than 201 is the ONLY signal that nothing new was written, so NoOp must be // keyed off status, not the result string. func TestAPISessionShipNoOp(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mr, err := r.MultipartReader() if err != nil { t.Errorf("request was not multipart: %v", err) w.WriteHeader(http.StatusInternalServerError) return } for { _, err := mr.NextPart() if err == io.EOF { break } if err != nil { t.Errorf("reading part: %v", err) w.WriteHeader(http.StatusInternalServerError) return } } w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]any{ "result": "shipped", "version": "1.2.3", "artifacts": map[string]any{}, }) })) defer ts.Close() dir := t.TempDir() file := filepath.Join(dir, "a.bin") if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil { t.Fatal(err) } s := newTestSession(ts.URL) res, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", map[string]string{"alpha-amd64": file}, nil, false) if err != nil { t.Fatalf("ship: %v", err) } if !res.NoOp { t.Fatalf("ship result = %+v, want NoOp true for a 200 identical re-ship", res) } } func TestAPISessionShipErrorSurfacesServerMessage(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusConflict) json.NewEncoder(w).Encode(map[string]string{ "error": "version 1.2.3 was already shipped with different content", }) })) defer ts.Close() dir := t.TempDir() file := filepath.Join(dir, "a.bin") if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil { t.Fatal(err) } s := newTestSession(ts.URL) _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", map[string]string{"alpha-amd64": file}, nil, false) if err == nil || !strings.Contains(err.Error(), "already shipped with different content") { t.Fatalf("ship error = %v, want the server's conflict message", err) } } // TestAPISessionShipSurvivesSlowServer is the CRITICAL fix's proof: a slow // server holds the connection open longer than an old-style absolute client // Timeout would tolerate, but well within ship's own generous context. // shipHTTPClientTimeout (auth.go) is the injectable seam that lets both // halves of the point run fast, without an actual multi-minute sleep: // shrunk to a few milliseconds, the request fails exactly the way the // pre-fix code (which reused the JSON verbs' 30s-Timeout client) would have // on a real large upload; restored to its production zero value, the same // slow server succeeds because only the context bounds the request. func TestAPISessionShipSurvivesSlowServer(t *testing.T) { t.Run("an absolute client Timeout fails against a slow server", func(t *testing.T) { release := make(chan struct{}) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-release io.Copy(io.Discard, r.Body) w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]any{"result": "shipped", "version": "1.2.3", "artifacts": map[string]any{}}) })) defer func() { close(release) ts.Close() }() dir := t.TempDir() file := filepath.Join(dir, "a.bin") if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil { t.Fatal(err) } s := newTestSession(ts.URL) old := shipHTTPClientTimeout shipHTTPClientTimeout = 30 * time.Millisecond defer func() { shipHTTPClientTimeout = old }() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _, err := s.ship(ctx, apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", map[string]string{"alpha-amd64": file}, nil, false) if err == nil { t.Fatal("ship with a tiny absolute client Timeout against a slow-to-answer server must fail") } }) t.Run("no absolute timeout survives the same slow server", func(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(150 * time.Millisecond) // longer than the tiny timeout above; well inside ship's context io.Copy(io.Discard, r.Body) w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]any{"result": "shipped", "version": "1.2.3", "artifacts": map[string]any{}}) })) defer ts.Close() dir := t.TempDir() file := filepath.Join(dir, "a.bin") if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil { t.Fatal(err) } s := newTestSession(ts.URL) // shipHTTPClientTimeout is back at its zero production default here // (the sibling subtest above restored it) — only the context bounds // this call. res, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", map[string]string{"alpha-amd64": file}, nil, false) if err != nil { t.Fatalf("ship against a slow-but-healthy server = %v, want success", err) } if res.NoOp { t.Fatal("a fresh (201) ship must not report NoOp") } }) } // TestAPISessionStableRefusal pins handleAPIPromote's 403 shape: the CLI // never resolves stable itself — it surfaces the console's own error AND the // console_url it hands back, because do() only keeps the bare "error" // string and a promote() caller needs the link too. func TestAPISessionStableRefusal(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) json.NewEncoder(w).Encode(map[string]string{ "error": "stable needs a passkey — finish in the console", "console_url": "http://x/a/b", }) })) defer ts.Close() s := newTestSession(ts.URL) _, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary", false, false) if err == nil { t.Fatal("promote against a stable refusal must error") } if !strings.Contains(err.Error(), "http://x/a/b") { t.Fatalf("promote error %q does not contain the console_url", err) } if !strings.Contains(err.Error(), "passkey") { t.Fatalf("promote error %q does not contain the console's refusal text", err) } } func TestAPISessionRollbackStableRefusal(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) json.NewEncoder(w).Encode(map[string]string{ "error": "stable needs a passkey — finish in the console", "console_url": "http://x/a/b", }) })) defer ts.Close() s := newTestSession(ts.URL) err := s.rollback(context.Background(), apiAcct{Seg: "acct"}, "app", "stable") if err == nil || !strings.Contains(err.Error(), "http://x/a/b") { t.Fatalf("rollback error = %v, want it to contain the console_url", err) } } func TestAPISessionPromoteSuccess(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer cli-token" { w.WriteHeader(http.StatusUnauthorized) return } var req struct{ Version, Channel string } json.NewDecoder(r.Body).Decode(&req) if req.Version != "1.2.3" || req.Channel != "canary/a" { t.Errorf("promote body = %+v", req) w.WriteHeader(http.StatusInternalServerError) return } json.NewEncoder(w).Encode(map[string]any{"result": "promoted", "from": "", "to": "canary"}) })) defer ts.Close() s := newTestSession(ts.URL) res, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary/a", false, false) if err != nil { t.Fatalf("promote: %v", err) } if res.NoOp { t.Fatal(`promote result = NoOp true, want false for a "promoted" result`) } } // TestAPISessionPromoteNoOp pins the noop-parity fix: PromoteOnLadder // already found this version on this channel, and handleAPIPromote answers // {"result": "noop"} (still 200) — promote() must report that as NoOp true // rather than folding it into an ordinary success indistinguishable from an // actual promotion. func TestAPISessionPromoteNoOp(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"result": "noop"}) })) defer ts.Close() s := newTestSession(ts.URL) res, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary/a", false, false) if err != nil { t.Fatalf("promote: %v", err) } if !res.NoOp { t.Fatalf(`promote result = %+v, want NoOp true for a "noop" result`, res) } } // TestAPISessionPromoteUnshippedVersionSurfacesServerText pins the // IMPORTANT fix: a 404 with a real JSON diagnosis (an unshipped version, // handleAPIPromote's release.GetManifest check) must reach the caller // verbatim, not be swallowed into the generic membership message that only // belongs to apiAccountApp's gate. func TestAPISessionPromoteUnshippedVersionSurfacesServerText(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{ "error": "Version 9.9.9 is not a shipped release of app.", }) })) defer ts.Close() s := newTestSession(ts.URL) _, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "9.9.9", "canary/a", false, false) if err == nil || !strings.Contains(err.Error(), "not a shipped release") { t.Fatalf("promote 404 with a JSON diagnosis = %v, want the server's own text", err) } if strings.Contains(err.Error(), "check --account and --app") { t.Fatalf("promote 404 with a real diagnosis must not be replaced by the membership message: %v", err) } } // TestAPISessionPromoteAccountGateNotFound is the companion case: the SAME // 404 status, but apiAccountApp's gate (an unknown account/app or one the // caller doesn't belong to) — a text/plain http.NotFound body with no // "error" field at all — must still get the generic membership message. func TestAPISessionPromoteAccountGateNotFound(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })) defer ts.Close() s := newTestSession(ts.URL) _, err := s.promote(context.Background(), apiAcct{Seg: "nope"}, "app", "1.2.3", "canary/a", false, false) if err == nil || !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") { t.Fatalf("promote against the account/app gate 404 = %v, want the membership message", err) } } // TestAPISessionChannelsNotFound pins the 404 case the parent task called // out specifically: apiAccountApp's gate answers http.NotFound, a // text/plain body, not JSON — the client must not choke on that and must // leave the caller with a sane, actionable message instead of the raw // "404 page not found" body. func TestAPISessionChannelsNotFound(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })) defer ts.Close() s := newTestSession(ts.URL) _, err := s.channels(context.Background(), apiAcct{Seg: "nope"}, "app") if err == nil { t.Fatal("channels against a 404 must error") } if !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") { t.Fatalf("channels 404 error = %q, want a message naming --account/--app", err) } } func TestAPISessionChannels(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ "channels": []map[string]any{ {"channel": "canary/a", "version": "1.2.3", "promoted_at": "2026-08-03T00:00:00Z", "rung": "canary"}, {"channel": "broken", "error": "signature mismatch"}, }, }) })) defer ts.Close() s := newTestSession(ts.URL) res, err := s.channels(context.Background(), apiAcct{Seg: "acct"}, "app") if err != nil { t.Fatalf("channels: %v", err) } chans := res.Channels if len(chans) != 2 { t.Fatalf("channels = %+v, want 2 rows", chans) } if chans[0].Channel != "canary/a" || chans[0].Version != "1.2.3" || chans[0].Rung != "canary" { t.Fatalf("row 0 = %+v", chans[0]) } if chans[1].Channel != "broken" || chans[1].Err != "signature mismatch" { t.Fatalf("row 1 = %+v", chans[1]) } // Neither "pipeline_entry" nor "pipeline_channels" present at all (an // older console, a legacy app, or handleAPIChannels' own // degrade-on-read-failure branch — this client has no need to tell // those apart, apiChannelsResult's doc comment): both decode to their // zero value. if res.PipelineEntry != "" || len(res.PipelineChannels) != 0 { t.Fatalf("no pipeline keys = %+v, want a zero-value pipeline", res) } } // TestAPISessionChannelsPipelineEnvelope pins the client-side decode of the // additive "pipeline_entry"/"pipeline_channels" flat top-level keys (release // pipelines v2, Task 11, review fix round 1 — NOT nested under a "pipeline" // key, since that JSON path already names the full release.Pipeline object // on the `/pipeline` door): PipelineEntry/PipelineChannels decode from the // two flat fields when present, and — both keys absent, the shape a legacy // app OR an unreadable-pipeline app both get, this client has no need to // tell them apart — decode to the same zero value. func TestAPISessionChannelsPipelineEnvelope(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ "channels": []map[string]any{}, "pipeline_entry": "edge", "pipeline_channels": []string{"edge", "production"}, }) })) defer ts.Close() s := newTestSession(ts.URL) res, err := s.channels(context.Background(), apiAcct{Seg: "acct"}, "app") if err != nil { t.Fatalf("channels: %v", err) } if res.PipelineEntry != "edge" { t.Fatalf("PipelineEntry = %q, want edge", res.PipelineEntry) } if want := []string{"edge", "production"}; !equalStrings(res.PipelineChannels, want) { t.Fatalf("PipelineChannels = %v, want %v", res.PipelineChannels, want) } ts2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"channels": []map[string]any{}}) })) defer ts2.Close() s2 := newTestSession(ts2.URL) res2, err := s2.channels(context.Background(), apiAcct{Seg: "acct"}, "app") if err != nil { t.Fatalf("channels: %v", err) } if res2.PipelineEntry != "" || len(res2.PipelineChannels) != 0 { t.Fatalf("both keys absent = %+v, want the zero value", res2) } } func equalStrings(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } // TestInstanceListSendsFilters pins two things at once (Task 10): instanceList's // own request construction (q/health land as query params, total/truncated // decoded from the response) and apiPath's "?"+"&" join fix — an -account-id // (Exact) session must still produce a well-formed URL, with BOTH exact=sqid // and the filters, when the tail already carries a query string of its own. func TestInstanceListSendsFilters(t *testing.T) { var gotPath string var gotQuery url.Values ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path gotQuery = r.URL.Query() json.NewEncoder(w).Encode(map[string]any{"instances": []map[string]any{}, "truncated": false, "total": 0}) })) defer ts.Close() s := newTestSession(ts.URL) _, truncated, total, err := s.instanceList(context.Background(), apiAcct{Seg: "bah", Exact: true}, "app", "api", "not-responding") if err != nil { t.Fatalf("instanceList: %v", err) } if truncated { t.Fatal("truncated = true, want false") } if total != 0 { t.Fatalf("total = %d, want 0", total) } if gotPath != "/api/cli/apps/bah/app/instances" { t.Fatalf("path = %q, want /api/cli/apps/bah/app/instances", gotPath) } if got := gotQuery.Get("q"); got != "api" { t.Fatalf("q = %q, want %q", got, "api") } if got := gotQuery.Get("health"); got != "not-responding" { t.Fatalf("health = %q, want %q", got, "not-responding") } if got := gotQuery.Get("exact"); got != "sqid" { t.Fatalf("exact = %q, want sqid — the account-id join must survive alongside q/health", got) } } // TestInstanceListDecodesUpstreams pins the LIST join's wire shape (Task 8): // each record's "upstreams" array decodes into apiInstanceRecord.Upstreams, // and a record carrying no such key (the ordinary, single-upstream majority) // decodes to nil rather than an empty-but-present slice — the distinction // upstreamsCell's "-" rendering depends on. func TestInstanceListDecodesUpstreams(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ "instances": []map[string]any{ {"host": "up.example.com", "upstreams": []map[string]any{ {"addr": "10.0.0.1:8090", "up": true}, {"addr": "10.0.0.2:8090", "up": false}, }}, {"host": "solo.example.com"}, }, "truncated": false, "total": 2, }) })) defer ts.Close() s := newTestSession(ts.URL) recs, _, _, err := s.instanceList(context.Background(), apiAcct{Seg: "bah"}, "app", "", "") if err != nil { t.Fatalf("instanceList: %v", err) } if len(recs) != 2 { t.Fatalf("recs = %+v, want 2", recs) } if len(recs[0].Upstreams) != 2 || !recs[0].Upstreams[0].Up || recs[0].Upstreams[1].Up { t.Fatalf("recs[0].Upstreams = %+v, want [10.0.0.1:8090 up, 10.0.0.2:8090 down]", recs[0].Upstreams) } if recs[1].Upstreams != nil { t.Fatalf("recs[1].Upstreams = %+v, want nil for a record with no upstreams key", recs[1].Upstreams) } } // TestInstanceCreateSendsAddrsAndProbe pins instanceCreate's request body // (Task 8): -addr/-health-path/-health-status land as addrs/probe on the // POST, alongside host/region/channel exactly as before, and are OMITTED // entirely (not sent as null or empty) when the caller passes neither — // today's create request, byte for byte, for a caller that never heard of // the new flags. func TestInstanceCreateSendsAddrsAndProbe(t *testing.T) { var gotBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("method = %s, want POST", r.Method) } b, _ := io.ReadAll(r.Body) gotBody = string(b) w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]any{"host": "a.example.com", "channel": "stable"}) })) defer ts.Close() s := newTestSession(ts.URL) rec, err := s.instanceCreate(context.Background(), apiAcct{Seg: "bah"}, "app", "a.example.com", "", "", "", "", []string{"10.0.0.1:8090", "10.0.0.2:8090"}, &instanceProbeReq{Path: "/healthz", Status: 200}, nil) if err != nil { t.Fatalf("instanceCreate: %v", err) } if rec.Host != "a.example.com" || rec.Channel != "stable" { t.Fatalf("rec = %+v", rec) } var got struct { Addrs []string `json:"addrs"` Probe struct { Path string `json:"path"` Status int `json:"status"` } `json:"probe"` } if err := json.Unmarshal([]byte(gotBody), &got); err != nil { t.Fatalf("request body unmarshal: %v\nbody: %s", err, gotBody) } if len(got.Addrs) != 2 || got.Addrs[0] != "10.0.0.1:8090" || got.Addrs[1] != "10.0.0.2:8090" { t.Fatalf("request addrs = %+v", got.Addrs) } if got.Probe.Path != "/healthz" || got.Probe.Status != 200 { t.Fatalf("request probe = %+v", got.Probe) } // No addrs/probe given at all: neither key crosses the wire. gotBody = "" if _, err := s.instanceCreate(context.Background(), apiAcct{Seg: "bah"}, "app", "b.example.com", "", "", "", "", nil, nil, nil); err != nil { t.Fatalf("instanceCreate (no addrs/probe): %v", err) } if strings.Contains(gotBody, "addrs") || strings.Contains(gotBody, "probe") { t.Fatalf("request body = %q, want no addrs/probe key when neither was given", gotBody) } } // TestInstanceSetUpstreamsPatches pins instanceSetUpstreams' whole contract // (Task 8, the CLI front over Task 7's PATCH): the HTTP method, the path // (mirrors instanceDelete's /instances/ escaping), the addrs/probe // wire shape, AND the three-way "absent vs explicit-empty vs a real value" // distinction the PATCH door depends on — a nil *[]string must send no // "addrs" key at all, while a non-nil pointer to an empty slice must send // "addrs":[] (the door's own clear spelling, apiinstances.go). func TestInstanceSetUpstreamsPatches(t *testing.T) { var gotMethod, gotPath, gotBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod, gotPath = r.Method, r.URL.Path b, _ := io.ReadAll(r.Body) gotBody = string(b) json.NewEncoder(w).Encode(map[string]any{"host": "a.example.com"}) })) defer ts.Close() s := newTestSession(ts.URL) addrs := []string{"10.0.0.1:8090", "10.0.0.2:8090"} rec, err := s.instanceSetUpstreams(context.Background(), apiAcct{Seg: "bah"}, "app", "a.example.com", &addrs, &instanceProbeReq{Path: "/up", Status: 204}) if err != nil { t.Fatalf("instanceSetUpstreams: %v", err) } if rec.Host != "a.example.com" { t.Fatalf("rec = %+v", rec) } if gotMethod != http.MethodPatch { t.Fatalf("method = %s, want PATCH", gotMethod) } if gotPath != "/api/cli/apps/bah/app/instances/a.example.com" { t.Fatalf("path = %q", gotPath) } var got struct { Addrs []string `json:"addrs"` Probe struct { Path string `json:"path"` Status int `json:"status"` } `json:"probe"` } if err := json.Unmarshal([]byte(gotBody), &got); err != nil { t.Fatalf("request body unmarshal: %v\nbody: %s", err, gotBody) } if len(got.Addrs) != 2 || got.Addrs[1] != "10.0.0.2:8090" { t.Fatalf("request addrs = %+v", got.Addrs) } if got.Probe.Path != "/up" || got.Probe.Status != 204 { t.Fatalf("request probe = %+v", got.Probe) } // addrs=nil (flag not given): no "addrs" key at all — "leave it alone". gotBody = "" if _, err := s.instanceSetUpstreams(context.Background(), apiAcct{Seg: "bah"}, "app", "a.example.com", nil, nil); err != nil { t.Fatalf("instanceSetUpstreams (nil addrs): %v", err) } if strings.Contains(gotBody, "addrs") { t.Fatalf("request body = %q, want no addrs key for a nil pointer", gotBody) } // addrs=&[]string{} (an explicit -addrs '' clear): "addrs":[] on the wire. gotBody = "" empty := []string{} if _, err := s.instanceSetUpstreams(context.Background(), apiAcct{Seg: "bah"}, "app", "a.example.com", &empty, nil); err != nil { t.Fatalf("instanceSetUpstreams (empty addrs): %v", err) } if !strings.Contains(gotBody, `"addrs":[]`) { t.Fatalf("request body = %q, want an explicit \"addrs\":[]", gotBody) } } // TestInstanceSetUpstreamsSurfacesRefusalVerbatim pins the self-review rule // this task carries: the PATCH door's own refusal text (ValidateShape's or // the "nothing to update" 400) reaches the caller byte-for-byte — it names // the offending upstream or the missing field, which is the only thing an // operator can act on, and paraphrasing it here would throw that away. func TestInstanceSetUpstreamsSurfacesRefusalVerbatim(t *testing.T) { const wantMsg = "addr \"garbage\": want a TCP upstream as host:port (e.g. 127.0.0.1:8090)" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]any{"error": wantMsg}) })) defer ts.Close() s := newTestSession(ts.URL) addrs := []string{"garbage"} _, err := s.instanceSetUpstreams(context.Background(), apiAcct{Seg: "bah"}, "app", "a.example.com", &addrs, nil) if err == nil { t.Fatal("instanceSetUpstreams: want an error, got nil") } if err.Error() != wantMsg { t.Fatalf("error = %q, want the door's own message %q verbatim", err.Error(), wantMsg) } } // TestInstanceSetSteeringPatches pins instanceSetSteering's whole contract // (Task 3, multi-region-steering plan): the HTTP method, the path (the same // /instances/ door set-upstreams uses), and — unlike addrs/probe — // that steering is ALWAYS sent on the wire, "" included, since this verb has // no "leave it alone" spelling: every invocation states the value the // caller wants. func TestInstanceSetSteeringPatches(t *testing.T) { var gotMethod, gotPath, gotBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod, gotPath = r.Method, r.URL.Path b, _ := io.ReadAll(r.Body) gotBody = string(b) json.NewEncoder(w).Encode(map[string]any{"host": "a.example.com", "steering": "latency"}) })) defer ts.Close() s := newTestSession(ts.URL) rec, err := s.instanceSetSteering(context.Background(), apiAcct{Seg: "bah"}, "app", "a.example.com", "latency") if err != nil { t.Fatalf("instanceSetSteering: %v", err) } if rec.Host != "a.example.com" || rec.Steering != "latency" { t.Fatalf("rec = %+v", rec) } if gotMethod != http.MethodPatch { t.Fatalf("method = %s, want PATCH", gotMethod) } if gotPath != "/api/cli/apps/bah/app/instances/a.example.com" { t.Fatalf("path = %q", gotPath) } if gotBody != `{"steering":"latency"}` { t.Fatalf("request body = %q, want a bare steering key", gotBody) } // Turning it off: the wire must still carry the key, `"steering":""`, not // omit it — an omitted key means "leave it alone" to the door, which // would silently do nothing for `carlos steering off`. gotBody = "" if _, err := s.instanceSetSteering(context.Background(), apiAcct{Seg: "bah"}, "app", "a.example.com", ""); err != nil { t.Fatalf("instanceSetSteering (off): %v", err) } if gotBody != `{"steering":""}` { t.Fatalf("request body = %q, want an explicit empty steering key", gotBody) } } // TestInstanceSetSteeringSurfacesRefusalVerbatim pins the same self-review // rule set-upstreams carries: the door's own refusal — the pool 409 or // ValidateShape's 400 — reaches the caller byte-for-byte, since it names // the only thing an operator can act on and paraphrasing it here would // throw that away. func TestInstanceSetSteeringSurfacesRefusalVerbatim(t *testing.T) { const wantMsg = "steering requires the shared pool — pool-scoped steering is a recorded future" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusConflict) json.NewEncoder(w).Encode(map[string]any{"error": wantMsg}) })) defer ts.Close() s := newTestSession(ts.URL) _, err := s.instanceSetSteering(context.Background(), apiAcct{Seg: "bah"}, "app", "a.example.com", "latency") if err == nil { t.Fatal("instanceSetSteering: want an error, got nil") } if err.Error() != wantMsg { t.Fatalf("error = %q, want the door's own message %q verbatim", err.Error(), wantMsg) } } // ---- Task 5: the four verbs wired to resolveMode -------------------------- // loginTo saves one live credential for origin/address and returns it, so a // verb-level test can log in without going through `carlos auth login`. func loginTo(t *testing.T, origin, address string) { t.Helper() f, err := loadCredentials() if err != nil { t.Fatal(err) } f.put(credential{Console: origin, Address: address, Token: "cli-token", ExpiresAt: time.Now().Add(time.Hour)}) if err := f.save(); err != nil { t.Fatal(err) } } // apiTestAccount is the -account value every API-mode verb test below uses. // Deliberately NOT sqid-shaped and not "ops": every prior round of these // tests happened to pass an -account value ("acct") that resolveAccount // would have resolved WITHOUT error had it wrongly been called (release. // ValidAccount("acct") happens to be true, so a stray resolveAccount call // would have silently "worked" and gone unnoticed). "vickys-team" fails // that same check, so a regression that starts calling acctOf()/ // resolveAccount in API mode again is loud (log.Fatal, or at minimum the // registry.db file requireNoRegistryTouch below watches for) rather than // silently passing. const apiTestAccount = "vickys-team" // requireNoRegistryTouch points CARLOS_DATA_DIR at a fresh, empty temp // directory and returns a func the caller runs AFTER the verb under test: // it fails the test if registry.db appeared there. accountFlags' resolver // (resolveAccount) is the only thing in this file that ever opens the // registry, and registry.Open creates the file on first open even if // resolution goes on to fail — so its presence is direct, mechanical proof // that API mode reached into the local registry, which it must never do // (there is no registry on a developer laptop to reach into). func requireNoRegistryTouch(t *testing.T) func() { t.Helper() dir := t.TempDir() t.Setenv("CARLOS_DATA_DIR", dir) return func() { t.Helper() path := filepath.Join(dir, "registry.db") if _, err := os.Stat(path); err == nil { t.Fatalf("%s was created — API mode must never open the local registry", path) } else if !os.IsNotExist(err) { t.Fatalf("stat %s: %v", path, err) } } } // newShipFlagSet builds the -account/-account-id/-registry flags // runShipAPI's callers register in production (accountFlags), parses account // plus the given positional file/dir args exactly as fs.Parse(os.Args) // would, and returns the FlagSet and the two raw account pointers runShipAPI // expects — letting a test drive runShipAPI directly (bypassing runShip's // own flag set and, crucially, its log.Fatal on error) so a refusal can be // asserted as a returned error instead of a process exit. func newShipFlagSet(t *testing.T, account string, args ...string) (*flag.FlagSet, *string, *string) { t.Helper() fs := flag.NewFlagSet("ship", flag.ContinueOnError) acctRaw, acctIDRaw, _ := accountFlags(fs) full := append([]string{"-account", account}, args...) if err := fs.Parse(full); err != nil { t.Fatalf("parse: %v", err) } return fs, acctRaw, acctIDRaw } // TestShipVerbAPIMode pins runShip's API-mode wiring end to end: with a live // credential and neither store env set, it must ship through the console // (not openStore/release.Ship) and print the first-use "using as //
" line ahead of the ordinary "shipped" line — no artifact count, // per the noop-parity amendment, since the API-mode message is deliberately // its own shape. func TestShipVerbAPIMode(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) var reqCount int32 mux := http.NewServeMux() mux.HandleFunc("POST /api/cli/apps/"+apiTestAccount+"/titogo/releases", func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&reqCount, 1) mr, err := r.MultipartReader() if err != nil { t.Errorf("not multipart: %v", err) w.WriteHeader(http.StatusInternalServerError) return } for { _, err := mr.NextPart() if err == io.EOF { break } if err != nil { t.Errorf("part: %v", err) w.WriteHeader(http.StatusInternalServerError) return } } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]any{ "result": "shipped", "version": "abc1234", "artifacts": map[string]any{}, }) }) ts := httptest.NewServer(mux) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") dir := t.TempDir() bin := filepath.Join(dir, "carlos") if err := os.WriteFile(bin, []byte("binary bytes"), 0o755); err != nil { t.Fatal(err) } buf := captureStdout(t) runShip([]string{"-app", "titogo", "-account", apiTestAccount, "-version", "abc1234", "-target", "linux-arm64", bin}) out := buf.String() if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) { t.Fatalf("stdout = %q, want it to contain %q", out, want) } if !strings.Contains(out, "shipped titogo abc1234") { t.Fatalf("stdout = %q, want the shipped line", out) } if got := atomic.LoadInt32(&reqCount); got != 1 { t.Fatalf("server got %d requests, want 1", got) } checkNoRegistry() } // TestShipVerbAPIModeNoOp pins the ship side of the noop-parity amendment: a // 200 (rather than 201) response is a re-ship of identical content, and the // verb must print the specified "already shipped ... nothing to do" line, not // the ordinary "shipped" line. func TestShipVerbAPIModeNoOp(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mr, err := r.MultipartReader() if err != nil { t.Fatalf("not multipart: %v", err) } for { if _, err := mr.NextPart(); err == io.EOF { break } else if err != nil { t.Fatalf("part: %v", err) } } w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]any{ "result": "shipped", "version": "abc1234", "artifacts": map[string]any{}, }) })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") dir := t.TempDir() bin := filepath.Join(dir, "carlos") if err := os.WriteFile(bin, []byte("binary bytes"), 0o755); err != nil { t.Fatal(err) } buf := captureStdout(t) runShip([]string{"-app", "titogo", "-account", apiTestAccount, "-version", "abc1234", "-target", "linux-arm64", bin}) out := buf.String() if want := "already shipped titogo abc1234 — identical content, nothing to do"; !strings.Contains(out, want) { t.Fatalf("stdout = %q, want %q", out, want) } if strings.Contains(out, "shipped titogo abc1234\n") { t.Fatalf("stdout = %q, must not ALSO print the ordinary shipped line", out) } checkNoRegistry() } // TestPromoteVerbAPIMode pins runPromote's API-mode wiring: it must promote // through the console and print the SAME "promoted" line the direct path // prints (cli.go's runPromote default case) — the verb, not the transport, // owns that wording. func TestPromoteVerbAPIMode(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) var reqCount int32 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&reqCount, 1) if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/promote" { t.Errorf("path = %s", r.URL.Path) } var req struct{ Version, Channel string } json.NewDecoder(r.Body).Decode(&req) if req.Version != "abc1234" || req.Channel != "canary/x" { t.Errorf("promote body = %+v", req) } json.NewEncoder(w).Encode(map[string]any{"result": "promoted", "from": "", "to": ""}) })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runPromote([]string{"-app", "titogo", "-account", apiTestAccount, "abc1234", "canary/x"}) out := buf.String() if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) { t.Fatalf("stdout = %q, want it to contain %q", out, want) } if want := "promoted titogo abc1234 -> canary/x"; !strings.Contains(out, want) { t.Fatalf("stdout = %q, want %q", out, want) } if got := atomic.LoadInt32(&reqCount); got != 1 { t.Fatalf("server got %d requests, want 1", got) } checkNoRegistry() } // TestPromoteVerbAPIModeAccountID pins -account-id's transport on a POST // route end to end (the GET side is covered server-side by apicli_test.go, // which drives only GETs): the sqid must land in the path segment AND the // request must carry ?exact=sqid, the console-side switch that stops the // segment from ever being name-matched. -account is set to a colliding // value at the same time to pin the supersede rule — the sqid wins, and the // name never reaches the wire. func TestPromoteVerbAPIModeAccountID(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) var reqCount int32 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&reqCount, 1) if r.URL.Path != "/api/cli/apps/bah/titogo/promote" { t.Errorf("path = %s, want the --account-id sqid as the segment", r.URL.Path) } if got := r.URL.Query().Get("exact"); got != "sqid" { t.Errorf("exact = %q, want %q — --account-id must never be name-matched", got, "sqid") } json.NewEncoder(w).Encode(map[string]any{"result": "promoted"}) })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runPromote([]string{"-app", "titogo", "-account", apiTestAccount, "-account-id", "bah", "abc1234", "canary/x"}) if want := "promoted titogo abc1234 -> canary/x"; !strings.Contains(buf.String(), want) { t.Fatalf("stdout = %q, want %q", buf.String(), want) } if got := atomic.LoadInt32(&reqCount); got != 1 { t.Fatalf("server got %d requests, want 1", got) } checkNoRegistry() } // TestPromoteVerbAPIModeNoOp pins the noop-parity amendment itself: API-mode // promote must print the byte-identical "already on X — nothing to do" shape // direct mode prints (runPromote's res.NoOp branch), not a differently-worded // API-mode-only message. func TestPromoteVerbAPIModeNoOp(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"result": "noop"}) })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runPromote([]string{"-app", "titogo", "-account", apiTestAccount, "abc1234", "canary/x"}) out := buf.String() if want := "titogo abc1234 is already on canary/x — nothing to do"; !strings.Contains(out, want) { t.Fatalf("stdout = %q, want %q", out, want) } checkNoRegistry() } // TestChannelsVerbAPIMode pins runChannels' API-mode wiring: it must read the // ladder through the console and print the same tabwriter table direct mode // prints, ERROR rows included (apiChannel.Err mirrors runChannels' per-row // ERROR behaviour, per apimode.go's doc comment) — plus the TAG column, which // comes from a SECOND read (GET .../tags) joined client-side, so the channels // endpoint's own wire shape stays untouched (release versioning spec, // 2026-08-08). func TestChannelsVerbAPIMode(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) base := "/api/cli/apps/" + apiTestAccount + "/titogo" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case base + "/channels": json.NewEncoder(w).Encode(map[string]any{ "channels": []map[string]any{ {"channel": "canary/a", "version": "1.2.3", "promoted_at": "2026-08-03T00:00:00Z", "rung": "canary"}, {"channel": "stable", "version": "0.5.0-3", "promoted_at": "2026-08-04T00:00:00Z", "rung": "stable"}, {"channel": "broken", "error": "signature mismatch"}, }, }) case base + "/tags": json.NewEncoder(w).Encode(map[string]any{ "tags": []map[string]any{ {"version": "0.5.0", "release": "0.5.0-3", "tagged_at": "2026-08-04T00:00:00Z", "actor": "vicky@keymail.dev"}, }, }) default: t.Errorf("path = %s", r.URL.Path) } })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runChannels([]string{"-app", "titogo", "-account", apiTestAccount}) out := buf.String() if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) { t.Fatalf("stdout = %q, want it to contain %q", out, want) } if !strings.Contains(out, "CHANNEL") || !strings.Contains(out, "VERSION") || !strings.Contains(out, "PROMOTED") || !strings.Contains(out, "TAG") { t.Fatalf("stdout = %q, want the tabwriter header", out) } if !strings.Contains(out, "canary/a") || !strings.Contains(out, "1.2.3") { t.Fatalf("stdout = %q, want the canary/a row", out) } if !strings.Contains(out, "broken") || !strings.Contains(out, "ERROR: signature mismatch") { t.Fatalf("stdout = %q, want the broken row's ERROR text", out) } // The tag lands on the row serving the release it was cut from, and on no // other row: the join is release -> tag, not "the newest tag wins". var stableRow, canaryRow string for _, line := range strings.Split(out, "\n") { switch { case strings.HasPrefix(line, "stable"): stableRow = strings.TrimRight(line, " ") case strings.HasPrefix(line, "canary/a"): canaryRow = strings.TrimRight(line, " ") } } // The DIGESTS cell closes every row now; the tag is the cell before it. if !strings.HasSuffix(stableRow, "0.5.0 "+textStateNo) { t.Fatalf("stable row = %q, want the tag 0.5.0 then the DIGESTS cell", stableRow) } if strings.Contains(canaryRow, "0.5.0") { t.Fatalf("canary row = %q, want no tag on an untagged version", canaryRow) } checkNoRegistry() } // TestVersionTargetVerbAPIMode pins runVersionTarget's API-mode wiring in all // three states it has: unset (a 200 with an empty target, NOT a 404), set, // and the write — which must be a PUT carrying {"target"}, the shape // handleAPIVersionTargetSet (internal/console/apiversion.go) reads. func TestVersionTargetVerbAPIMode(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) path := "/api/cli/apps/" + apiTestAccount + "/titogo/version-target" var stored string var gotPutBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != path { t.Errorf("path = %s", r.URL.Path) return } switch r.Method { case http.MethodGet: if stored == "" { json.NewEncoder(w).Encode(map[string]string{"target": ""}) return } json.NewEncoder(w).Encode(map[string]any{ "target": stored, "set_by": "vicky@keymail.dev", "set_at": "2026-08-08T00:00:00Z", }) case http.MethodPut: b, _ := io.ReadAll(r.Body) gotPutBody = strings.TrimSpace(string(b)) var req struct { Target string `json:"target"` } json.Unmarshal(b, &req) stored = req.Target json.NewEncoder(w).Encode(map[string]string{"target": stored}) default: t.Errorf("method = %s", r.Method) } })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runVersionTarget([]string{"-app", "titogo", "-account", apiTestAccount}) if out := buf.String(); !strings.Contains(out, "no version target set") { t.Fatalf("unset target stdout = %q", out) } buf = captureStdout(t) runVersionTarget([]string{"-app", "titogo", "-account", apiTestAccount, "0.5.0"}) if out := buf.String(); !strings.Contains(out, "version target 0.5.0") { t.Fatalf("set stdout = %q", out) } if want := `{"target":"0.5.0"}`; gotPutBody != want { t.Fatalf("PUT body = %q, want %q", gotPutBody, want) } buf = captureStdout(t) runVersionTarget([]string{"-app", "titogo", "-account", apiTestAccount}) out := buf.String() if !strings.Contains(out, "version target 0.5.0") { t.Fatalf("get-after-set stdout = %q", out) } if !strings.Contains(out, "set by vicky@keymail.dev") { t.Fatalf("get-after-set stdout = %q, want the provenance line", out) } checkNoRegistry() } // TestReleasesVerbAPIMode pins runReleases' API-mode wiring: one GET, the // server's row ORDER preserved verbatim (the console already sorts newest // first — re-sorting client-side would be a second opinion nobody asked // for), and every column rendered. func TestReleasesVerbAPIMode(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/releases" { t.Errorf("path = %s", r.URL.Path) return } json.NewEncoder(w).Encode(map[string]any{ "releases": []map[string]any{ {"version": "0.5.0-4", "kind": "binary", "built_at": "2026-08-08T10:00:00Z", "label": "and the sidebar", "tag": ""}, {"version": "0.5.0-3", "kind": "binary", "built_at": "2026-08-07T10:00:00Z", "label": "fixing modals", "tag": "0.5.0"}, }, }) })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runReleases([]string{"-app", "titogo", "-account", apiTestAccount}) out := buf.String() lines := strings.Split(strings.TrimRight(out, "\n"), "\n") if len(lines) < 4 { t.Fatalf("stdout = %q, want a using line, a header and two rows", out) } if !strings.Contains(lines[1], "VERSION") || !strings.Contains(lines[1], "LABEL") || !strings.Contains(lines[1], "TAG") { t.Fatalf("header = %q", lines[1]) } if !strings.Contains(lines[2], "0.5.0-4") || !strings.Contains(lines[2], "and the sidebar") { t.Fatalf("first row = %q, want the server's first row", lines[2]) } if !strings.Contains(lines[3], "0.5.0-3") || !strings.HasSuffix(strings.TrimRight(lines[3], " "), "0.5.0") { t.Fatalf("second row = %q, want the tagged row carrying its tag", lines[3]) } checkNoRegistry() } // TestReleasesVerbAPIModeEmpty: an app with nothing shipped gets a sentence, // not a bare header — the empty table would otherwise read as a failure. func TestReleasesVerbAPIModeEmpty(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"releases": []map[string]any{}}) })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runReleases([]string{"-app", "titogo", "-account", apiTestAccount}) if out := buf.String(); !strings.Contains(out, "no releases yet") { t.Fatalf("stdout = %q, want the empty-state line", out) } checkNoRegistry() } // TestRollbackVerbAPIMode pins runRollback's API-mode wiring. func TestRollbackVerbAPIMode(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) var reqCount int32 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&reqCount, 1) if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/rollback" { t.Errorf("path = %s", r.URL.Path) } var req struct{ Channel string } json.NewDecoder(r.Body).Decode(&req) if req.Channel != "canary/x" { t.Errorf("rollback body = %+v", req) } json.NewEncoder(w).Encode(map[string]string{"result": "rolled back"}) })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runRollback([]string{"-app", "titogo", "-account", apiTestAccount, "canary/x"}) out := buf.String() if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) { t.Fatalf("stdout = %q, want it to contain %q", out, want) } if want := "rolled back titogo canary/x"; !strings.Contains(out, want) { t.Fatalf("stdout = %q, want %q", out, want) } if got := atomic.LoadInt32(&reqCount); got != 1 { t.Fatalf("server got %d requests, want 1", got) } checkNoRegistry() } // TestPromoteVerbDirectUnchanged pins the other half of resolveMode's // contract: with CARLOS_DEPLOYMENT_DIR set, runPromote must take the existing // direct-bucket path unchanged and never dial a console at all — even one it // holds a live (and, if it mistakenly used it, perfectly usable) credential // for. The httptest server's request counter is the proof: it must stay 0. func TestPromoteVerbDirectUnchanged(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) var reqCount int32 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&reqCount, 1) w.WriteHeader(http.StatusInternalServerError) })) defer ts.Close() // A live credential pointed AT the counting server: direct mode must win // regardless (CARLOS_DEPLOYMENT_DIR set below), so this must never be // dialed — if it ever were, reqCount would catch it. loginTo(t, ts.URL, "vicky@keymail.dev") dir := t.TempDir() t.Setenv("CARLOS_DEPLOYMENT_DIR", dir) seed, pub, err := release.GenerateKey() if err != nil { t.Fatal(err) } t.Setenv("CARLOS_RELEASE_KEY", seed) t.Setenv("CARLOS_RELEASE_PUBKEY", pub) bin := filepath.Join(t.TempDir(), "carlos") if err := os.WriteFile(bin, []byte("binary bytes"), 0o755); err != nil { t.Fatal(err) } buf := captureStdout(t) runShip([]string{"-app", "demoapp", "-version", "abc1234", "-target", "linux-arm64", bin}) runPromote([]string{"-app", "demoapp", "abc1234", "canary/x"}) out := buf.String() // Pins ship's direct-mode print format exactly (review round, MINOR 8): // unlike API mode's deliberately different "shipped " // (no count), direct mode still names the artifact count — this must // stay byte-identical to what it printed before Task 5 touched this verb. if want := "shipped demoapp abc1234 (1 artifacts)"; !strings.Contains(out, want) { t.Fatalf("stdout = %q, want the direct-mode shipped line", out) } if want := "promoted demoapp abc1234 -> canary/x"; !strings.Contains(out, want) { t.Fatalf("stdout = %q, want the direct-mode promoted line", out) } if got := atomic.LoadInt32(&reqCount); got != 0 { t.Fatalf("server got %d requests, want 0 — direct mode must never dial the console", got) } } // ---- Task 5 review round: runShipAPI's static/multi-file/pre-flight paths - // TestShipAPIStaticNamesSiteTarGz pins runShipAPI's static branch end to end // (review round, IMPORTANT 2 — previously zero coverage): the packed tarball // must reach the server in a part named EXACTLY "site.tar.gz", carrying the // same bytes edge.PackDir itself produces for the same directory — proof the // scratch-temp-file plumbing (PackDir -> temp file -> multipart part) does // not mangle anything in between. func TestShipAPIStaticNamesSiteTarGz(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) siteDir := t.TempDir() if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("

hi

"), 0o644); err != nil { t.Fatal(err) } wantBytes, err := edge.PackDir(siteDir) if err != nil { t.Fatal(err) } var gotName string var gotBytes []byte ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mr, err := r.MultipartReader() if err != nil { t.Fatalf("not multipart: %v", err) } for { part, err := mr.NextPart() if err == io.EOF { break } if err != nil { t.Fatalf("part: %v", err) } b, err := io.ReadAll(part) if err != nil { t.Fatalf("read part: %v", err) } if part.FormName() != "meta" { gotName = part.FormName() gotBytes = b } } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]any{"result": "shipped", "version": "abc1234", "artifacts": map[string]any{}}) })) defer ts.Close() sess := newTestSession(ts.URL) fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, siteDir) if _, err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", "", "", "", nil, true, false); err != nil { t.Fatalf("runShipAPI: %v", err) } if gotName != "site.tar.gz" { t.Fatalf("server received artifact part named %q, want %q", gotName, "site.tar.gz") } if !bytes.Equal(gotBytes, wantBytes) { t.Fatalf("server received %d bytes, want the %d bytes edge.PackDir produced for the same directory", len(gotBytes), len(wantBytes)) } checkNoRegistry() } // TestShipAPIStaticCleansUpTempFileOnServerError pins the temp-file-leak fix // (review round, MINOR 4): runShipAPI used to call log.Fatal on a failing // sess.ship, which calls os.Exit and skips every deferred function — // including the static kind's scratch temp file cleanup, up to 256MiB per // invocation. Returning an error instead (the caller log.Fatals) lets that // defer actually run. func TestShipAPIStaticCleansUpTempFileOnServerError(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) siteDir := t.TempDir() if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("hi"), 0o644); err != nil { t.Fatal(err) } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.Copy(io.Discard, r.Body) w.WriteHeader(http.StatusInternalServerError) })) defer ts.Close() tmpParent := os.TempDir() before, err := os.ReadDir(tmpParent) if err != nil { t.Fatal(err) } beforeNames := map[string]bool{} for _, e := range before { beforeNames[e.Name()] = true } sess := newTestSession(ts.URL) fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, siteDir) if _, err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", "", "", "", nil, true, false); err == nil { t.Fatal("runShipAPI against a failing server must error") } after, err := os.ReadDir(tmpParent) if err != nil { t.Fatal(err) } for _, e := range after { if beforeNames[e.Name()] { continue } if strings.HasPrefix(e.Name(), "carlos-ship-") { t.Fatalf("temp file %s leaked after a failed ship", e.Name()) } } checkNoRegistry() } // TestShipAPIMultiFileGoodBasenames pins the multi-file binary ship path // (review round, IMPORTANT 2 + MINOR 7's good case): every basename shaped // like a GOOS-GOARCH target ships as its own part, named exactly its // basename. func TestShipAPIMultiFileGoodBasenames(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) dir := t.TempDir() fileArm := filepath.Join(dir, "linux-arm64") fileAmd := filepath.Join(dir, "linux-amd64") if err := os.WriteFile(fileArm, []byte("arm bytes"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(fileAmd, []byte("amd bytes"), 0o755); err != nil { t.Fatal(err) } var reqCount int32 gotParts := map[string]bool{} ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&reqCount, 1) mr, err := r.MultipartReader() if err != nil { t.Fatalf("not multipart: %v", err) } for { part, err := mr.NextPart() if err == io.EOF { break } if err != nil { t.Fatalf("part: %v", err) } io.Copy(io.Discard, part) if part.FormName() != "meta" { gotParts[part.FormName()] = true } } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]any{"result": "shipped", "version": "abc1234", "artifacts": map[string]any{}}) })) defer ts.Close() sess := newTestSession(ts.URL) fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, fileArm, fileAmd) if _, err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", "", "", nil, true, false); err != nil { t.Fatalf("runShipAPI: %v", err) } if !gotParts["linux-arm64"] || !gotParts["linux-amd64"] || len(gotParts) != 2 { t.Fatalf("server received parts %v, want exactly linux-arm64 and linux-amd64", gotParts) } if got := atomic.LoadInt32(&reqCount); got != 1 { t.Fatalf("server got %d requests, want 1", got) } checkNoRegistry() } // TestShipAPIMultiFileBadBasenameRefused pins the client-side name check // (review round, MINOR 7's bad case): a multi-file binary ship whose // basename doesn't fit the server's own GOOS-GOARCH shape (targetRe, // internal/console/apicli.go) must be refused before ever dialing the // console, naming the offending file. func TestShipAPIMultiFileBadBasenameRefused(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) dir := t.TempDir() fileGood := filepath.Join(dir, "linux-arm64") fileBad := filepath.Join(dir, "output.bin") if err := os.WriteFile(fileGood, []byte("arm bytes"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(fileBad, []byte("bad bytes"), 0o755); err != nil { t.Fatal(err) } var reqCount int32 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&reqCount, 1) w.WriteHeader(http.StatusInternalServerError) })) defer ts.Close() sess := newTestSession(ts.URL) fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, fileGood, fileBad) _, err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", "", "", nil, true, false) if err == nil { t.Fatal("runShipAPI with a bad basename must error") } if !strings.Contains(err.Error(), "output.bin") { t.Fatalf("error %q does not name the offending file", err) } if !strings.Contains(err.Error(), "invalid artifact name") { t.Fatalf("error %q does not explain the refusal", err) } if got := atomic.LoadInt32(&reqCount); got != 0 { t.Fatalf("server got %d requests, want 0 — a bad basename must be refused before dialing the console", got) } checkNoRegistry() } // TestShipAPIMissingArtifactRefusedBeforeDialing pins the pre-flight stat // (review round, MINOR 6): a typo'd artifact path must fail clean before the // console is ever dialed, not after. func TestShipAPIMissingArtifactRefusedBeforeDialing(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) missing := filepath.Join(t.TempDir(), "linux-arm64") // never created var reqCount int32 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&reqCount, 1) w.WriteHeader(http.StatusInternalServerError) })) defer ts.Close() sess := newTestSession(ts.URL) fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, missing) _, err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", "", "", nil, true, false) if err == nil { t.Fatal("runShipAPI with a missing artifact path must error") } if got := atomic.LoadInt32(&reqCount); got != 0 { t.Fatalf("server got %d requests, want 0 — a missing path must be refused before dialing the console", got) } checkNoRegistry() } // TestAPISessionShipCarriesCapabilitiesInMeta pins the API-mode half of the // declaration surface: the block rides the EXISTING meta part (so the server // has read the whole declaration before it reads an artifact byte and can // refuse the upload at its first part), as the CANONICAL re-marshal of the // decoded Set rather than the operator's file verbatim — bounded and // deterministic, which is what keeps it comfortably inside the 8KiB // io.LimitReader handleAPIShip reads meta under. // // TestAPISessionShipMultipart above pins the other half by omission: with no // capabilities the meta part is still exactly {"version":..,"kind":..}, byte // for byte what this verb sent before the field existed. func TestAPISessionShipCarriesCapabilitiesInMeta(t *testing.T) { var metaBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mr, err := r.MultipartReader() if err != nil { t.Errorf("request was not multipart: %v", err) w.WriteHeader(http.StatusInternalServerError) return } for { p, err := mr.NextPart() if err != nil { break } b, _ := io.ReadAll(p) if p.FormName() == "meta" { metaBody = string(b) } } w.WriteHeader(http.StatusCreated) // A CURRENT console echoes the stored block back (2026-08-03 review, // I3); this verb refuses a success that does not, so the fake has to // behave like the console it stands in for. w.Write([]byte(`{"result":"shipped","capabilities":{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}]}}`)) })) defer ts.Close() dir := t.TempDir() file := filepath.Join(dir, "a") if err := os.WriteFile(file, []byte("A"), 0o644); err != nil { t.Fatal(err) } var set *caps.Set if err := json.Unmarshal([]byte(`{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}]}`), &set); err != nil { t.Fatal(err) } s := newTestSession(ts.URL) if _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", map[string]string{"alpha-amd64": file}, set, false); err != nil { t.Fatalf("ship: %v", err) } want := `{"version":"1.2.3","kind":"binary","capabilities":{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}]}}` if metaBody != want { t.Fatalf("meta part = %q, want %q", metaBody, want) } } // TestAPISessionShipCarriesProgramChangeInMeta: --program-change rides in the // meta part as "program_change":true, and is omitted when false, so a ship // without the flag sends the bytes this verb sent before the flag existed. func TestAPISessionShipCarriesProgramChangeInMeta(t *testing.T) { for _, tc := range []struct { change bool want string }{ {true, `{"version":"1.2.3","kind":"binary","program_change":true}`}, {false, `{"version":"1.2.3","kind":"binary"}`}, } { var metaBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mr, err := r.MultipartReader() if err != nil { t.Errorf("request was not multipart: %v", err) w.WriteHeader(http.StatusInternalServerError) return } for { p, err := mr.NextPart() if err != nil { break } b, _ := io.ReadAll(p) if p.FormName() == "meta" { metaBody = string(b) } } w.WriteHeader(http.StatusCreated) w.Write([]byte(`{"result":"shipped"}`)) })) s := newTestSession(ts.URL) _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", shipTestArtifact(t), nil, tc.change) ts.Close() if err != nil { t.Fatalf("ship (program_change %v): %v", tc.change, err) } if metaBody != tc.want { t.Fatalf("meta part (program_change %v) = %q, want %q", tc.change, metaBody, tc.want) } } } // shipEchoServer is a console stand-in for the capability-echo tests: it // accepts any multipart ship and answers 201 with the body given. func shipEchoServer(t *testing.T, body string) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mr, err := r.MultipartReader() if err != nil { t.Errorf("request was not multipart: %v", err) w.WriteHeader(http.StatusInternalServerError) return } for { p, err := mr.NextPart() if err != nil { break } io.Copy(io.Discard, p) } w.WriteHeader(http.StatusCreated) w.Write([]byte(body)) })) } // shipTestArtifact writes one throwaway artifact file and returns the files // map ship takes. func shipTestArtifact(t *testing.T) map[string]string { t.Helper() file := filepath.Join(t.TempDir(), "a") if err := os.WriteFile(file, []byte("A"), 0o644); err != nil { t.Fatal(err) } return map[string]string{"alpha-amd64": file} } // shipTestCaps is the declaration the echo tests send. func shipTestCaps(t *testing.T) *caps.Set { t.Helper() var set *caps.Set if err := json.Unmarshal([]byte(`{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}]}`), &set); err != nil { t.Fatal(err) } return set } // TestAPISessionShipAcceptsAMatchingCapabilitiesEcho is the ordinary path: a // current console stores what was sent and echoes it, so the ship succeeds. // The echo check must not turn every capability-bearing ship into a refusal. func TestAPISessionShipAcceptsAMatchingCapabilitiesEcho(t *testing.T) { ts := shipEchoServer(t, `{"result":"shipped","version":"1.2.3","capabilities":{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}]}}`) defer ts.Close() s := newTestSession(ts.URL) res, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", shipTestArtifact(t), shipTestCaps(t), false) if err != nil { t.Fatalf("ship with a matching echo: %v", err) } if res.NoOp { t.Fatal("a 201 ship must not report NoOp") } } // TestAPISessionShipRefusesAConsoleThatDropsCapabilities is the REMOVAL // signal, simulated exactly as an un-upgraded console produces it: the server // ignores the unknown "capabilities" key (Go's decoder does this by default), // stores a manifest declaring nothing, and answers 201 with no echo. That // manifest is the instruction to DELETE the drop-ins on every box — so a 201 // here has to be a hard failure at the terminal, naming the fix, rather than a // success the operator then promotes. func TestAPISessionShipRefusesAConsoleThatDropsCapabilities(t *testing.T) { ts := shipEchoServer(t, `{"result":"shipped","version":"1.2.3","artifacts":{}}`) defer ts.Close() s := newTestSession(ts.URL) _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", shipTestArtifact(t), shipTestCaps(t), false) if err == nil { t.Fatal("a console that echoed no capability block was accepted — that is an old console silently dropping the declaration") } if !strings.Contains(err.Error(), "update the console binary") { t.Errorf("refusal does not name the fix: %v", err) } } // TestAPISessionShipRefusesADifferentCapabilitiesEcho: a console that // understood the key but stored something else. The STORED block is what boxes // converge onto, so a mismatch is refused too, and the message shows both — // there is no version of this where the CLI's copy is the one that matters. func TestAPISessionShipRefusesADifferentCapabilitiesEcho(t *testing.T) { ts := shipEchoServer(t, `{"result":"shipped","version":"1.2.3","capabilities":{"v":1,"daemon":true}}`) defer ts.Close() s := newTestSession(ts.URL) _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", shipTestArtifact(t), shipTestCaps(t), false) if err == nil { t.Fatal("a console that stored a DIFFERENT capability block was accepted") } for _, want := range []string{"differs", "port", "must not be promoted"} { if !strings.Contains(err.Error(), want) { t.Errorf("refusal %q does not mention %q", err, want) } } } // TestAPISessionShipWithoutCapabilitiesNeedsNoEcho: the fleet's ordinary ship // sends no block and must not start demanding one back. An echo check that // forgot to be conditional would refuse every ship the fleet does today. func TestAPISessionShipWithoutCapabilitiesNeedsNoEcho(t *testing.T) { ts := shipEchoServer(t, `{"result":"shipped","version":"1.2.3","artifacts":{}}`) defer ts.Close() s := newTestSession(ts.URL) if _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "", shipTestArtifact(t), nil, false); err != nil { t.Fatalf("ship without --capabilities must not require an echo: %v", err) } } // ---- Task 6: carlos ledger — apiSession methods and the CLI verbs -------- // TestAPISessionLedgerAppend pins the wire shape handleAPILedgerAppend // (internal/console/apiledger.go) requires: bearer auth, POST to // .../ledgers//append, body {"payload": }. func TestAPISessionLedgerAppend(t *testing.T) { var gotAuth, gotPath, gotBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") gotPath = r.URL.Path b, _ := io.ReadAll(r.Body) gotBody = string(b) json.NewEncoder(w).Encode(ledger.Appended{Key: "ledgers/acct/app/carbon/entries/x.json", SHA256: strings.Repeat("a", 64), Count: 3}) })) defer ts.Close() s := newTestSession(ts.URL) a, err := s.ledgerAppend(context.Background(), apiAcct{Seg: "acct"}, "app", "carbon", json.RawMessage(`{"n":1}`)) if err != nil { t.Fatalf("ledgerAppend: %v", err) } if gotAuth != "Bearer cli-token" { t.Fatalf("Authorization = %q, want Bearer cli-token", gotAuth) } if gotPath != "/api/cli/apps/acct/app/ledgers/carbon/append" { t.Fatalf("path = %q", gotPath) } if !strings.Contains(gotBody, `"payload":{"n":1}`) { t.Fatalf("request body = %q, want the payload carried verbatim", gotBody) } if a.Count != 3 { t.Fatalf("Count = %d, want 3", a.Count) } } // TestAPISessionLedgerAppendBusy pins ledgerStoreError's contention status: // a 503 with a JSON "error" body must surface that text, not a generic // status-only message — it is what tells a caller "retry", not "stop". func TestAPISessionLedgerAppendBusy(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) json.NewEncoder(w).Encode(map[string]string{"error": "the ledger is busy right now — try again in a moment"}) })) defer ts.Close() s := newTestSession(ts.URL) _, err := s.ledgerAppend(context.Background(), apiAcct{Seg: "acct"}, "app", "carbon", json.RawMessage(`{"n":1}`)) if err == nil || !strings.Contains(err.Error(), "busy") { t.Fatalf("ledgerAppend 503 error = %v, want it to surface the busy message", err) } } // TestAPISessionLedgerHeadNotFound pins ledgerHead's DEDICATED 404 message — // deliberately not apiErrorFromBody's generic "-account/-app" text, since an // unknown LEDGER under an app the caller does belong to is a different, far // more common case ("nothing appended yet"). func TestAPISessionLedgerHeadNotFound(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })) defer ts.Close() s := newTestSession(ts.URL) _, err := s.ledgerHead(context.Background(), apiAcct{Seg: "acct"}, "app", "carbon") if err == nil { t.Fatal("ledgerHead against a 404 must error") } if !strings.Contains(err.Error(), `"carbon"`) { t.Fatalf("error = %q, want it to name the ledger", err) } if strings.Contains(err.Error(), "-account") { t.Fatalf("error = %q, must NOT fall back to the generic membership message", err) } } func TestAPISessionLedgerHead(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(ledger.Head{Schema: 1, UpdatedAt: "2026-08-03T00:00:00Z", HeadKey: "k", HeadSHA256: strings.Repeat("b", 64), Count: 5}) })) defer ts.Close() s := newTestSession(ts.URL) h, err := s.ledgerHead(context.Background(), apiAcct{Seg: "acct"}, "app", "carbon") if err != nil { t.Fatalf("ledgerHead: %v", err) } if h.Count != 5 || h.HeadKey != "k" { t.Fatalf("head = %+v", h) } } // TestAPISessionLedgerList pins ledgerList's raw-body contract: the caller // (runLedgerList) decodes {"manifest","ledgers"} itself, so a null manifest // survives round-trip as nil rather than a caller-side zero value that would // erase the "never published" distinction. func TestAPISessionLedgerList(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"manifest": nil, "ledgers": []string{"carbon"}}) })) defer ts.Close() s := newTestSession(ts.URL) body, err := s.ledgerList(context.Background(), apiAcct{Seg: "acct"}, "app") if err != nil { t.Fatalf("ledgerList: %v", err) } var res struct { Manifest *ledger.Manifest `json:"manifest"` Ledgers []string `json:"ledgers"` } if err := json.Unmarshal(body, &res); err != nil { t.Fatalf("unmarshal: %v", err) } if res.Manifest != nil { t.Fatalf("manifest = %+v, want nil", res.Manifest) } if len(res.Ledgers) != 1 || res.Ledgers[0] != "carbon" { t.Fatalf("ledgers = %v, want [carbon]", res.Ledgers) } } // TestAPISessionLedgerPublish: publish is read-modify-write server-side, so // the answer is the RESULTING manifest — the set that is public now, which // may name ledgers this request never mentioned. A method that discarded the // body would leave the CLI with nothing honest to print. func TestAPISessionLedgerPublish(t *testing.T) { var gotPath, gotBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path b, _ := io.ReadAll(r.Body) gotBody = string(b) json.NewEncoder(w).Encode(ledger.Manifest{ Schema: 1, Public: true, Ledgers: []string{"prompts", "carbon"}, Title: "T"}) })) defer ts.Close() s := newTestSession(ts.URL) m, err := s.ledgerPublish(context.Background(), apiAcct{Seg: "acct"}, "app", ledger.Manifest{Public: true, Ledgers: []string{"carbon"}, Title: "T"}) if err != nil { t.Fatalf("ledgerPublish: %v", err) } if !m.Public || len(m.Ledgers) != 2 || m.Ledgers[1] != "carbon" { t.Fatalf("ledgerPublish = %+v, want the server's RESULTING manifest, not the request", m) } if gotPath != "/api/cli/apps/acct/app/ledgers/publish" { t.Fatalf("path = %q", gotPath) } if !strings.Contains(gotBody, `"public":true`) || !strings.Contains(gotBody, `"carbon"`) { t.Fatalf("body = %q", gotBody) } } // TestAPISessionLedgerBlob pins ledgerBlob's request plumbing — the one // method that hand-builds its request rather than going through // getJSON/postJSON, because the wire body is raw bytes, not JSON. Same base // URL and bearer header as getJSON, POST instead of GET, a bytes.Reader body // instead of nil. func TestAPISessionLedgerBlob(t *testing.T) { var gotAuth, gotPath string var gotBody []byte ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") gotPath = r.URL.Path gotBody, _ = io.ReadAll(r.Body) json.NewEncoder(w).Encode(map[string]string{"sha256": ledger.SHA256Hex(gotBody)}) })) defer ts.Close() s := newTestSession(ts.URL) sha, err := s.ledgerBlob(context.Background(), apiAcct{Seg: "acct"}, "app", "carbon", []byte("corpus")) if err != nil { t.Fatalf("ledgerBlob: %v", err) } if gotAuth != "Bearer cli-token" { t.Fatalf("Authorization = %q, want Bearer cli-token", gotAuth) } if gotPath != "/api/cli/apps/acct/app/ledgers/carbon/blobs" { t.Fatalf("path = %q", gotPath) } if string(gotBody) != "corpus" { t.Fatalf("request body = %q, want the raw bytes verbatim", gotBody) } if want := ledger.SHA256Hex([]byte("corpus")); sha != want { t.Fatalf("sha256 = %q, want %q", sha, want) } } // TestAPISessionLedgerBlobError pins ledgerBlob's error path — a non-200 // status decodes through apiErrorFromBody exactly like every other method // here, even though the request itself was raw bytes, not JSON. func TestAPISessionLedgerBlobError(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusRequestEntityTooLarge) json.NewEncoder(w).Encode(map[string]string{"error": "blob exceeds the 4 MiB cap"}) })) defer ts.Close() s := newTestSession(ts.URL) _, err := s.ledgerBlob(context.Background(), apiAcct{Seg: "acct"}, "app", "carbon", []byte("x")) if err == nil || !strings.Contains(err.Error(), "4 MiB cap") { t.Fatalf("ledgerBlob error = %v, want the server's 413 message surfaced", err) } } // TestLedgerVerbsOfferNoRegistryFlag: -registry names the local database // that resolves an account in DIRECT-bucket mode, and ledgerAPISession // refuses direct mode outright (spec §2.3). A ledger verb that advertised // -registry would be offering a mode it will then refuse — and the flag // would be dead besides, since nothing on this path opens a registry (the // requireNoRegistryTouch assertions elsewhere in this file are the other // half of that proof). func TestLedgerVerbsOfferNoRegistryFlag(t *testing.T) { fs := flag.NewFlagSet("ledger", flag.ContinueOnError) ledgerAccountFlags(fs) if fs.Lookup("account") == nil { t.Fatal("ledger verbs must still take --account") } // -account-id is the way out of an ambiguity the console reports (409), // so a ledger verb that offered -account without it would name a fix-up // flag its own caller cannot type. if fs.Lookup("account-id") == nil { t.Fatal("ledger verbs must take --account-id — it is what the console's ambiguity 409 tells the caller to use") } if f := fs.Lookup("registry"); f != nil { t.Fatalf("ledger verbs register --registry (%q) — direct-bucket mode is refused, not configured", f.Usage) } } // ---- Task 6: the ledger verbs wired to resolveMode / public HTTPS -------- // TestLedgerAppendVerbAPIMode pins runLedgerAppend's console wiring: it // reads the entry from a file argument, validates it client-side (already // smoke-tested via `go run . ledger append` — invalid JSON never reaches // this server, see the task report), and posts through the console. func TestLedgerAppendVerbAPIMode(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) var reqCount int32 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&reqCount, 1) if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers/carbon/append" { t.Errorf("path = %s", r.URL.Path) } var in struct { Payload json.RawMessage `json:"payload"` } json.NewDecoder(r.Body).Decode(&in) if string(in.Payload) != `{"n":1}` { t.Errorf("payload = %s, want {\"n\":1}", in.Payload) } json.NewEncoder(w).Encode(ledger.Appended{Key: "k", SHA256: strings.Repeat("c", 64), Count: 7}) })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") dir := t.TempDir() entryPath := filepath.Join(dir, "entry.json") if err := os.WriteFile(entryPath, []byte(`{"n":1}`), 0o600); err != nil { t.Fatal(err) } buf := captureStdout(t) runLedgerAppend([]string{"-app", "titogo", "-account", apiTestAccount, "carbon", entryPath}) out := buf.String() if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) { t.Fatalf("stdout = %q, want it to contain %q", out, want) } if !strings.Contains(out, "appended to carbon") || !strings.Contains(out, "#7") { t.Fatalf("stdout = %q, want the appended line naming the ledger and count 7", out) } if !strings.Contains(out, strings.Repeat("c", 12)) { t.Fatalf("stdout = %q, want the sha's first 12 hex chars", out) } if got := atomic.LoadInt32(&reqCount); got != 1 { t.Fatalf("server got %d requests, want 1", got) } checkNoRegistry() } // TestLedgerAppendVerbReadsStdin pins the "-" convention: the entry argument // "-" reads the payload from stdin rather than treating "-" as a filename. func TestLedgerAppendVerbReadsStdin(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) var gotPayload string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var in struct { Payload json.RawMessage `json:"payload"` } json.NewDecoder(r.Body).Decode(&in) gotPayload = string(in.Payload) json.NewEncoder(w).Encode(ledger.Appended{Key: "k", SHA256: strings.Repeat("d", 64), Count: 1}) })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") r, w, err := os.Pipe() if err != nil { t.Fatal(err) } if _, err := w.WriteString(`{"n":2}`); err != nil { t.Fatal(err) } w.Close() origStdin := os.Stdin os.Stdin = r t.Cleanup(func() { os.Stdin = origStdin }) buf := captureStdout(t) runLedgerAppend([]string{"-app", "titogo", "-account", apiTestAccount, "carbon", "-"}) if gotPayload != `{"n":2}` { t.Fatalf("payload the server received = %q, want the stdin content", gotPayload) } if !strings.Contains(buf.String(), "appended to carbon") { t.Fatalf("stdout = %q", buf.String()) } } // TestLedgerHeadVerbAPIMode pins the one deliberately-different output shape // in this file: head prints ONLY the HEAD JSON (no "using ... as ..." // banner line) so a script can pipe it straight into `jq`. func TestLedgerHeadVerbAPIMode(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers/carbon/head" { t.Errorf("path = %s", r.URL.Path) } json.NewEncoder(w).Encode(ledger.Head{ Schema: 1, UpdatedAt: "2026-08-03T00:00:00Z", HeadKey: "ledgers/x/HEAD.json", HeadSHA256: strings.Repeat("e", 64), Count: 9, }) })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runLedgerHead([]string{"-app", "titogo", "-account", apiTestAccount, "carbon"}) out := buf.String() if strings.Contains(out, "using ") { t.Fatalf("stdout = %q, head must print ONLY the HEAD JSON, no banner line", out) } var h ledger.Head if err := json.Unmarshal([]byte(out), &h); err != nil { t.Fatalf("head output is not valid JSON: %v (%q)", err, out) } if h.Count != 9 { t.Fatalf("head.Count = %d, want 9", h.Count) } } // Note: runLedgerHead's 404 path ends in log.Fatal(err), which exits the // process — untestable in-process, matching this file's existing convention // (captureStdout's own doc comment above: "log.Fatal paths are untouched by // this seam on purpose"). The error text itself, which is what a human // actually reads, is pinned at the apiSession layer instead, by // TestAPISessionLedgerHeadNotFound above. func TestLedgerListVerbAPIMode(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers" { t.Errorf("path = %s", r.URL.Path) } json.NewEncoder(w).Encode(map[string]any{ "manifest": map[string]any{"schema": 1, "public": true, "ledgers": []string{"carbon"}, "title": "Titogo"}, "ledgers": []string{"carbon", "prompts"}, }) })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runLedgerList([]string{"-app", "titogo", "-account", apiTestAccount}) out := buf.String() if !strings.Contains(out, `published "Titogo"`) || !strings.Contains(out, "carbon") { t.Fatalf("stdout = %q, want the published-manifest line naming the title and ledgers", out) } if !strings.Contains(out, "prompts") { t.Fatalf("stdout = %q, want the prompts ledger listed even though it isn't in the manifest", out) } } // TestLedgerListVerbUnpublished pins the null-manifest case: "never // published" must read as its own message, not an empty/zero-value table. func TestLedgerListVerbUnpublished(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"manifest": nil, "ledgers": []string{"carbon"}}) })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runLedgerList([]string{"-app", "titogo", "-account", apiTestAccount}) out := buf.String() if !strings.Contains(out, "not published") { t.Fatalf("stdout = %q, want a not-published message for a null manifest", out) } if !strings.Contains(out, "carbon") { t.Fatalf("stdout = %q, want the carbon ledger still listed", out) } } func TestLedgerPublishVerbAPIMode(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) var gotPath, gotBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path b, _ := io.ReadAll(r.Body) gotBody = string(b) // The server's answer names a ledger this call never mentioned: // publish is additive, so the resulting set is bigger than the ask. json.NewEncoder(w).Encode(ledger.Manifest{ Schema: 1, Public: true, Ledgers: []string{"prompts", "carbon"}, Title: "Titogo"}) })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runLedgerPublish([]string{"-app", "titogo", "-account", apiTestAccount, "-title", "Titogo", "carbon"}) if gotPath != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers/publish" { t.Fatalf("path = %q", gotPath) } if !strings.Contains(gotBody, `"public":true`) || !strings.Contains(gotBody, `"carbon"`) || !strings.Contains(gotBody, `"Titogo"`) { t.Fatalf("publish body = %q", gotBody) } // The line reports the RESULTING public set, not the request's argument // — printing "carbon now public" while prompts is also public is how a // caller ends up believing they unpublished something they didn't. if !strings.Contains(buf.String(), "published titogo: public ledgers now [prompts carbon]") { t.Fatalf("stdout = %q, want the resulting public set", buf.String()) } } // TestLedgerPublishVerbUnpublish pins -public=false's own message (an // unpublish is not the same act as a publish, and must not read like one), // and that it too reports what is left public rather than what was removed. func TestLedgerPublishVerbUnpublish(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) var gotBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) gotBody = string(b) json.NewEncoder(w).Encode(ledger.Manifest{ Schema: 1, Public: true, Ledgers: []string{"prompts"}, Title: "Titogo"}) })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runLedgerPublish([]string{"-app", "titogo", "-account", apiTestAccount, "-public=false", "carbon"}) if !strings.Contains(gotBody, `"public":false`) { t.Fatalf("publish body = %q, want public:false", gotBody) } if !strings.Contains(buf.String(), "unpublished titogo: public ledgers now [prompts]") { t.Fatalf("stdout = %q, want what is still public", buf.String()) } } // TestLedgerPublishVerbUnpublishLast: when the last ledger goes, the line // says so plainly rather than printing an empty bracket. func TestLedgerPublishVerbUnpublishLast(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(ledger.Manifest{Schema: 1, Public: false, Title: "Titogo"}) })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runLedgerPublish([]string{"-app", "titogo", "-account", apiTestAccount, "-public=false", "carbon"}) if !strings.Contains(buf.String(), "unpublished titogo: no public ledgers") { t.Fatalf("stdout = %q, want the emptied-set message", buf.String()) } } func TestLedgerBlobVerbAPIMode(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers/prompts/blobs" { t.Errorf("path = %s", r.URL.Path) } b, _ := io.ReadAll(r.Body) json.NewEncoder(w).Encode(map[string]string{"sha256": ledger.SHA256Hex(b)}) })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") dir := t.TempDir() path := filepath.Join(dir, "transcript.txt") if err := os.WriteFile(path, []byte("corpus bytes"), 0o600); err != nil { t.Fatal(err) } buf := captureStdout(t) runLedgerBlob([]string{"-app", "titogo", "-account", apiTestAccount, "prompts", path}) want := ledger.SHA256Hex([]byte("corpus bytes")) if !strings.Contains(buf.String(), want) { t.Fatalf("stdout = %q, want it to contain the uploaded blob's sha256 %q", buf.String(), want) } } // TestLedgerBlobVerbMultipleFiles pins that "blob ..." takes // every trailing file argument, one upload per file. func TestLedgerBlobVerbMultipleFiles(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) var reqCount int32 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&reqCount, 1) b, _ := io.ReadAll(r.Body) json.NewEncoder(w).Encode(map[string]string{"sha256": ledger.SHA256Hex(b)}) })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") dir := t.TempDir() pathA := filepath.Join(dir, "a.txt") pathB := filepath.Join(dir, "b.txt") if err := os.WriteFile(pathA, []byte("aaa"), 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(pathB, []byte("bbb"), 0o600); err != nil { t.Fatal(err) } buf := captureStdout(t) runLedgerBlob([]string{"-app", "titogo", "-account", apiTestAccount, "prompts", pathA, pathB}) out := buf.String() if !strings.Contains(out, ledger.SHA256Hex([]byte("aaa"))) || !strings.Contains(out, ledger.SHA256Hex([]byte("bbb"))) { t.Fatalf("stdout = %q, want both files' sha256", out) } if got := atomic.LoadInt32(&reqCount); got != 2 { t.Fatalf("server got %d requests, want 2 (one per file)", got) } } // TestLedgerVerifyVerb pins runLedgerVerify's own wiring: -url trimmed and // handed to ledger.HTTPGetter, -account treated as a bare sqid (never // resolved), and the printed message. Deliberately does NOT call // useTempCredentials/clearDeploymentEnv/loginTo — the whole point of // "verify -url" is that it needs no credential and no console session at // all, and this test proves it by never configuring either. func TestLedgerVerifyVerb(t *testing.T) { dir := t.TempDir() st := release.NewDirStore(dir) fixedNow := func(i int) func() time.Time { return func() time.Time { return time.Date(2026, 8, 3, 0, i, 0, 0, time.UTC) } } for i := range 2 { if _, err := ledger.Append(context.Background(), st, "bab", "platform", "carbon", "w", json.RawMessage(`{"i":1}`), fixedNow(i)); err != nil { t.Fatal(err) } } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _, err := st.Get(r.Context(), strings.TrimPrefix(r.URL.Path, "/")) if err != nil { http.NotFound(w, r) return } w.Write(b) })) defer ts.Close() buf := captureStdout(t) // Trailing slash on -url, deliberately, to pin strings.TrimRight. runLedgerVerify([]string{"-app", "platform", "-account", "bab", "-url", ts.URL + "/", "carbon"}) if !strings.Contains(buf.String(), "verified: 2 entries") { t.Fatalf("stdout = %q, want the verified line", buf.String()) } } // ---- Task 3: config client methods (configGet/configApply/configSync) ---- // TestAPISessionConfigGetFullShape pins configGet's decode of the full wire // shape GET /api/cli/apps/{account}/{app}/config sends (spec §2): bundle, // exec_delivery, secrets_pubkey, environments, and vars of both kinds — a // secret row carries no value on the wire, and apiConfigVar.Value decodes to // "" for it exactly as it would for an env var that happened to be empty. func TestAPISessionConfigGetFullShape(t *testing.T) { var gotPath string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path json.NewEncoder(w).Encode(map[string]any{ "bundle": "default", "exec_delivery": "file", "secrets_pubkey": "abcdef==", "secrets_account": "bab", "environments": []string{"staging"}, "vars": []map[string]any{ {"key": "PORT", "kind": "env", "value": "8080"}, {"key": "BANK_KEY", "kind": "secret"}, }, }) })) defer ts.Close() s := newTestSession(ts.URL) cfg, err := s.configGet(context.Background(), apiAcct{Seg: "acct"}, "app", "") if err != nil { t.Fatalf("configGet: %v", err) } if gotPath != "/api/cli/apps/acct/app/config" { t.Fatalf("path = %q", gotPath) } if cfg.Bundle != "default" || cfg.ExecDelivery != "file" || cfg.SecretsPubkey != "abcdef==" || cfg.SecretsAccount != "bab" { t.Fatalf("config = %+v", cfg) } if len(cfg.Environments) != 1 || cfg.Environments[0] != "staging" { t.Fatalf("environments = %v, want [staging]", cfg.Environments) } if len(cfg.Vars) != 2 { t.Fatalf("vars = %+v, want 2 rows", cfg.Vars) } if cfg.Vars[0] != (apiConfigVar{Key: "PORT", Kind: "env", Value: "8080"}) { t.Fatalf("vars[0] = %+v", cfg.Vars[0]) } if cfg.Vars[1] != (apiConfigVar{Key: "BANK_KEY", Kind: "secret", Value: ""}) { t.Fatalf("vars[1] = %+v, want a secret row with no value", cfg.Vars[1]) } } // TestAPISessionConfigGetAbsentFieldsDecodeEmpty pins the omitted-field // case: an empty app's response (no exec_delivery, no secrets_pubkey, per // handleAPIConfigGet's own doc comment) must decode to "" for both, not // error and not panic on a missing key. func TestAPISessionConfigGetAbsentFieldsDecodeEmpty(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ "bundle": "default", "environments": []string{}, "vars": []any{}, }) })) defer ts.Close() s := newTestSession(ts.URL) cfg, err := s.configGet(context.Background(), apiAcct{Seg: "acct"}, "app", "") if err != nil { t.Fatalf("configGet: %v", err) } if cfg.ExecDelivery != "" { t.Fatalf("ExecDelivery = %q, want \"\" for an omitted field", cfg.ExecDelivery) } if cfg.SecretsPubkey != "" { t.Fatalf("SecretsPubkey = %q, want \"\" for an omitted field", cfg.SecretsPubkey) } // An older console names no account; the CLI then seals unbound. if cfg.SecretsAccount != "" { t.Fatalf("SecretsAccount = %q, want \"\" for an omitted field", cfg.SecretsAccount) } if len(cfg.Vars) != 0 { t.Fatalf("Vars = %+v, want none", cfg.Vars) } } // TestAPISessionConfigGetEnvironmentQueryParam pins the query-param rule: // environment=="" sends NO ?environment= at all (the server's own zero // value for "default bundle" is an absent parameter, not one carrying ""), // while a named environment sends it verbatim. func TestAPISessionConfigGetEnvironmentQueryParam(t *testing.T) { var gotRawQuery string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotRawQuery = r.URL.RawQuery json.NewEncoder(w).Encode(map[string]any{"bundle": "default", "environments": []string{}, "vars": []any{}}) })) defer ts.Close() s := newTestSession(ts.URL) if _, err := s.configGet(context.Background(), apiAcct{Seg: "acct"}, "app", ""); err != nil { t.Fatalf("configGet: %v", err) } if gotRawQuery != "" { t.Fatalf("query for environment=\"\" = %q, want no query at all", gotRawQuery) } if _, err := s.configGet(context.Background(), apiAcct{Seg: "acct"}, "app", "staging"); err != nil { t.Fatalf("configGet: %v", err) } if gotRawQuery != "environment=staging" { t.Fatalf("query for environment=\"staging\" = %q, want environment=staging", gotRawQuery) } } // TestAPISessionConfigGetExactAndEnvironment pins the COMBINATION, which // neither single-parameter test covers: apiPath already appended // ?exact=sqid for an -account-id call, so configGet's own append has to // pick "&" instead of assuming "?" — get that wrong and the environment // name is swallowed into the exact value, silently reading the DEFAULT // bundle while the console name-matches a segment it was told not to. func TestAPISessionConfigGetExactAndEnvironment(t *testing.T) { var gotRawQuery, gotExact, gotEnvironment, gotPath string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotRawQuery, gotPath = r.URL.RawQuery, r.URL.Path gotExact = r.URL.Query().Get("exact") gotEnvironment = r.URL.Query().Get("environment") json.NewEncoder(w).Encode(map[string]any{"bundle": "staging", "environments": []string{}, "vars": []any{}}) })) defer ts.Close() s := newTestSession(ts.URL) if _, err := s.configGet(context.Background(), apiAcct{Seg: "bah", Exact: true}, "app", "staging"); err != nil { t.Fatalf("configGet: %v", err) } if want := "exact=sqid&environment=staging"; gotRawQuery != want { t.Fatalf("query = %q, want %q", gotRawQuery, want) } // The server's own view: both parameters parse as their own values, // which is the property the separator choice actually protects. if gotExact != "sqid" || gotEnvironment != "staging" { t.Fatalf("server saw exact=%q environment=%q, want exact=sqid environment=staging", gotExact, gotEnvironment) } if want := "/api/cli/apps/bah/app/config"; gotPath != want { t.Fatalf("path = %q, want %q", gotPath, want) } } // TestEnvListVerbAPIModeAccountIDAndEnvironment is // TestPromoteVerbAPIModeAccountID's shape for the config surface: -account-id // must reach the wire from an env/secrets verb too — sqid in the segment, // ?exact=sqid alongside, and the colliding -account name never sent — with a // -environment in play at the same time, which is the config verbs' own // wrinkle. func TestEnvListVerbAPIModeAccountIDAndEnvironment(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) var reqCount int32 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&reqCount, 1) if want := "/api/cli/apps/bah/titogo/config"; r.URL.Path != want { t.Errorf("path = %s, want %s (the --account-id sqid as the segment)", r.URL.Path, want) } if got := r.URL.Query().Get("exact"); got != "sqid" { t.Errorf("exact = %q, want %q — --account-id must never be name-matched", got, "sqid") } if got := r.URL.Query().Get("environment"); got != "staging" { t.Errorf("environment = %q, want %q", got, "staging") } if strings.Contains(r.URL.String(), apiTestAccount) { t.Errorf("url %s carries the superseded --account name", r.URL.String()) } json.NewEncoder(w).Encode(map[string]any{ "bundle": "staging", "environments": []string{"staging"}, "vars": []map[string]string{{"key": "PORT", "kind": "env", "value": "8080"}}, }) })) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") // No captureStdout here: runEnvList's printing goes to os.Stdout // directly (printEnvList's tabwriter), not the swappable stdout var the // promote-family tests capture. The assertions that matter are the wire // ones above, inside the handler. runEnvList([]string{"-app", "titogo", "-environment", "staging", "-account", apiTestAccount, "-account-id", "bah"}) if got := atomic.LoadInt32(&reqCount); got != 1 { t.Fatalf("server got %d requests, want 1", got) } checkNoRegistry() } // TestAPISessionConfigGetNotFound pins the gate's byte-identical 404 — // http.NotFound's text/plain body, no JSON "error" field — reaching the // same generic membership message every other client method here falls // back to. func TestAPISessionConfigGetNotFound(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })) defer ts.Close() s := newTestSession(ts.URL) _, err := s.configGet(context.Background(), apiAcct{Seg: "nope"}, "app", "") if err == nil || !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") { t.Fatalf("configGet 404 error = %v, want the membership message", err) } } // TestAPISessionConfigApplyEnvVarBody pins the exact wire body configApply // sends for a plain env var set — appconfig.Var's OWN json tags produce // {"kind":"env","value":"8080"} with no "envelope" key (omitempty), so no // separate wire struct is needed in apimode.go. func TestAPISessionConfigApplyEnvVarBody(t *testing.T) { var gotMethod, gotPath, gotBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod = r.Method gotPath = r.URL.Path b, _ := io.ReadAll(r.Body) gotBody = string(b) json.NewEncoder(w).Encode(map[string]any{"result": "ok", "removed": 0}) })) defer ts.Close() s := newTestSession(ts.URL) _, err := s.configApply(context.Background(), apiAcct{Seg: "acct"}, "app", apiConfigApply{ Environment: "", Set: map[string]appconfig.Var{"PORT": {Kind: "env", Value: "8080"}}, }) if err != nil { t.Fatalf("configApply: %v", err) } if gotMethod != http.MethodPut { t.Fatalf("method = %q, want PUT", gotMethod) } if gotPath != "/api/cli/apps/acct/app/config" { t.Fatalf("path = %q", gotPath) } if want := `{"environment":"","set":{"PORT":{"kind":"env","value":"8080"}}}`; gotBody != want { t.Fatalf("body = %q, want %q", gotBody, want) } } // TestAPISessionConfigApplySecretEnvelopeBody pins the secret-set body: a // sealed envelope, no "value" key (appconfig.Var's Value field omitempty). func TestAPISessionConfigApplySecretEnvelopeBody(t *testing.T) { var gotBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) gotBody = string(b) json.NewEncoder(w).Encode(map[string]any{"result": "ok", "removed": 0}) })) defer ts.Close() s := newTestSession(ts.URL) _, err := s.configApply(context.Background(), apiAcct{Seg: "acct"}, "app", apiConfigApply{ Environment: "", Set: map[string]appconfig.Var{"BANK_KEY": {Kind: "secret", Envelope: "sealedbytes=="}}, }) if err != nil { t.Fatalf("configApply: %v", err) } if want := `{"environment":"","set":{"BANK_KEY":{"kind":"secret","envelope":"sealedbytes=="}}}`; gotBody != want { t.Fatalf("body = %q, want %q", gotBody, want) } } // TestAPISessionConfigApplyUnsetBody pins an unset-only request: no "set" // key at all (map omitempty), "unset" carries the keys. func TestAPISessionConfigApplyUnsetBody(t *testing.T) { var gotBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) gotBody = string(b) json.NewEncoder(w).Encode(map[string]any{"result": "ok", "removed": 1}) })) defer ts.Close() s := newTestSession(ts.URL) res, err := s.configApply(context.Background(), apiAcct{Seg: "acct"}, "app", apiConfigApply{ Environment: "", Unset: []string{"OLD_KEY", "ALSO_MISSING"}, }) if err != nil { t.Fatalf("configApply: %v", err) } if want := `{"environment":"","unset":["OLD_KEY","ALSO_MISSING"]}`; gotBody != want { t.Fatalf("body = %q, want %q", gotBody, want) } // removed is the server's ACTUAL count, never the argument count — two // keys were asked for, only one was really removed. if res.Removed != 1 { t.Fatalf("removed = %d, want 1 (the server's count, not len(Unset)==2)", res.Removed) } // A response with no "restart" key at all leaves Restart nil — the // "console said nothing" state, which the CLI renders as silence rather // than as "nothing needs restarting". if res.Restart != nil { t.Fatalf("Restart = %+v, want nil for a response that omits the field", res.Restart) } } // TestAPISessionConfigApplyExecDeliveryPresent pins the exec_delivery // pointer's PRESENT case: the field appears on the wire with the exact // string the pointer carries — "off" here, never "" (see runEnvExecDelivery // in appconfig_cli.go — the CLI itself never sends ""). func TestAPISessionConfigApplyExecDeliveryPresent(t *testing.T) { var gotBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) gotBody = string(b) json.NewEncoder(w).Encode(map[string]any{"result": "ok", "removed": 0}) })) defer ts.Close() s := newTestSession(ts.URL) off := "off" if _, err := s.configApply(context.Background(), apiAcct{Seg: "acct"}, "app", apiConfigApply{ExecDelivery: &off}); err != nil { t.Fatalf("configApply: %v", err) } if want := `{"environment":"","exec_delivery":"off"}`; gotBody != want { t.Fatalf("body = %q, want %q", gotBody, want) } } // TestAPISessionConfigApplyExecDeliveryAbsent pins the ABSENT case: a nil // pointer omits "exec_delivery" from the wire entirely (omitempty on the // pointer field) — "no change requested", distinct from an explicit "off". func TestAPISessionConfigApplyExecDeliveryAbsent(t *testing.T) { var gotBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) gotBody = string(b) json.NewEncoder(w).Encode(map[string]any{"result": "ok", "removed": 0}) })) defer ts.Close() s := newTestSession(ts.URL) if _, err := s.configApply(context.Background(), apiAcct{Seg: "acct"}, "app", apiConfigApply{Set: map[string]appconfig.Var{"K": {Kind: "env", Value: "v"}}}); err != nil { t.Fatalf("configApply: %v", err) } if strings.Contains(gotBody, "exec_delivery") { t.Fatalf("body = %q, must not carry exec_delivery when the pointer is nil", gotBody) } } // TestAPISessionConfigApplyNotFound pins the gate's generic 404 message, // mirroring every other client method's fallback. func TestAPISessionConfigApplyNotFound(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })) defer ts.Close() s := newTestSession(ts.URL) _, err := s.configApply(context.Background(), apiAcct{Seg: "nope"}, "app", apiConfigApply{Unset: []string{"K"}}) if err == nil || !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") { t.Fatalf("configApply 404 error = %v, want the membership message", err) } } // TestAPISessionConfigApplyErrorBody pins a real JSON diagnosis (a CAS // conflict, say) surfacing verbatim, not folded into the generic message. func TestAPISessionConfigApplyErrorBody(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusConflict) json.NewEncoder(w).Encode(map[string]string{"error": "config changed concurrently — try again"}) })) defer ts.Close() s := newTestSession(ts.URL) _, err := s.configApply(context.Background(), apiAcct{Seg: "acct"}, "app", apiConfigApply{Unset: []string{"K"}}) if err == nil || !strings.Contains(err.Error(), "config changed concurrently") { t.Fatalf("configApply 409 error = %v, want the server's own message", err) } } // TestAPISessionConfigSync pins the sync door's method/path and success. func TestAPISessionConfigSync(t *testing.T) { var gotMethod, gotPath string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod = r.Method gotPath = r.URL.Path json.NewEncoder(w).Encode(map[string]string{"result": "requested"}) })) defer ts.Close() s := newTestSession(ts.URL) if err := s.configSync(context.Background(), apiAcct{Seg: "acct"}, "app"); err != nil { t.Fatalf("configSync: %v", err) } if gotMethod != http.MethodPost { t.Fatalf("method = %q, want POST", gotMethod) } if gotPath != "/api/cli/apps/acct/app/config/sync" { t.Fatalf("path = %q", gotPath) } } // TestAPISessionConfigSyncError pins error passthrough on the sync door. func TestAPISessionConfigSyncError(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"error": "could not store the config — try again"}) })) defer ts.Close() s := newTestSession(ts.URL) err := s.configSync(context.Background(), apiAcct{Seg: "acct"}, "app") if err == nil || !strings.Contains(err.Error(), "could not store the config") { t.Fatalf("configSync error = %v, want the server's message", err) } } // TestAPISessionCreateApp: the createApp client sends the claim to the // literal `new` door with the name in the body, honours -account-id's // exact=sqid transport, surfaces the server's own refusal sentence, and // rewrites the bare 404 into create's account-only wording (there is no // -app to second-guess on this verb). func TestAPISessionCreateApp(t *testing.T) { var gotPath, gotQuery, gotName string status := http.StatusOK ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath, gotQuery = r.URL.Path, r.URL.RawQuery var req struct { Name string `json:"name"` } json.NewDecoder(r.Body).Decode(&req) gotName = req.Name if status != http.StatusOK { w.WriteHeader(status) if status == http.StatusConflict { json.NewEncoder(w).Encode(map[string]string{"error": "that name is taken"}) } return } json.NewEncoder(w).Encode(map[string]any{ "account": "bae", "app": req.Name, "url": "https://console.example/a/bae/apps/" + req.Name, }) })) defer ts.Close() s := newTestSession(ts.URL) res, err := s.createApp(context.Background(), apiAcct{Seg: "keymail"}, "paul", "", false, "", "", "") if err != nil { t.Fatalf("createApp: %v", err) } if gotPath != "/api/cli/apps/keymail/new" || gotQuery != "" || gotName != "paul" { t.Fatalf("request = %s?%s name=%q", gotPath, gotQuery, gotName) } if res.Account != "bae" || res.App != "paul" || res.URL != "https://console.example/a/bae/apps/paul" { t.Fatalf("result = %+v", res) } if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae", Exact: true}, "paul", "", false, "", "", ""); err != nil { t.Fatalf("createApp exact: %v", err) } if gotQuery != "exact=sqid" { t.Fatalf("exact query = %q, want exact=sqid", gotQuery) } status = http.StatusConflict if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "paul", "", false, "", "", ""); err == nil || err.Error() != "that name is taken" { t.Fatalf("409 err = %v, want the server's sentence", err) } status = http.StatusNotFound if _, err := s.createApp(context.Background(), apiAcct{Seg: "nope"}, "paul", "", false, "", "", ""); err == nil || !strings.Contains(err.Error(), "check --account,") { t.Fatalf("404 err = %v, want create's account-only wording", err) } } // TestAPISessionCreateAppSendsPlace: the optional place argument (`carlos // apps create -place fleet/`, 2026-08-06 ruling) travels in the create // body's "place" field, and an empty place is simply absent from the // request semantics the server reads as "no place given" either way — this // pins that the WIRE VALUE sent is exactly what was passed, not silently // dropped or renamed. func TestAPISessionCreateAppSendsPlace(t *testing.T) { var gotPlace string var sawPlaceKey bool ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var raw map[string]any body, _ := io.ReadAll(r.Body) json.Unmarshal(body, &raw) _, sawPlaceKey = raw["place"] var req struct { Name string `json:"name"` Place string `json:"place"` } json.Unmarshal(body, &req) gotPlace = req.Place json.NewEncoder(w).Encode(map[string]any{ "account": "bae", "app": req.Name, "url": "https://console.example/a/bae/apps/" + req.Name, }) })) defer ts.Close() s := newTestSession(ts.URL) if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "titogo", "fleet/pi", false, "", "", ""); err != nil { t.Fatalf("createApp with place: %v", err) } if gotPlace != "fleet/pi" { t.Fatalf("wire place = %q, want fleet/pi", gotPlace) } if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "unplaced", "", false, "", "", ""); err != nil { t.Fatalf("createApp with no place: %v", err) } if sawPlaceKey && gotPlace != "" { t.Fatalf("empty place must not travel as a non-empty value; got %q", gotPlace) } } // accountsConsole is a console stub for the omitted--account resolution // tests: it serves GET /api/cli/apps with the given status and body, and // counts how often the endpoint is consulted. func accountsConsole(t *testing.T, status int, body string) (*apiSession, *int) { t.Helper() hits := 0 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/cli/apps" || r.Method != http.MethodGet { t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) http.NotFound(w, r) return } hits++ w.WriteHeader(status) io.WriteString(w, body) })) t.Cleanup(ts.Close) return newTestSession(ts.URL), &hits } func TestDefaultAccountSingleMembership(t *testing.T) { useTempCredentials(t) out := captureStdout(t) s, hits := accountsConsole(t, http.StatusOK, `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","role":"owner","apps":[]}]}`) a, err := s.defaultAccount(context.Background()) if err != nil || a.Seg != "bdk" || !a.Exact { t.Fatalf("one membership = %+v, %v; want its sqid, exact", a, err) } if *hits != 1 { t.Fatalf("endpoint hits = %d, want 1", *hits) } if !strings.Contains(out.String(), "account: tom@keymail.dev (bdk)") { t.Fatalf("the resolved account must be printed; got:\n%s", out.String()) } } // forceTerminal pins interactiveTerminal for one test — the real check // reads process file descriptors, which a test controls in neither // direction (under `go test` stdin is /dev/null, a char device). func forceTerminal(t *testing.T, tty bool) { t.Helper() old := interactiveTerminal interactiveTerminal = func() bool { return tty } t.Cleanup(func() { interactiveTerminal = old }) } func TestDefaultAccountSeveralNonTTY(t *testing.T) { useTempCredentials(t) captureStdout(t) forceTerminal(t, false) s, _ := accountsConsole(t, http.StatusOK, `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev"},{"sqid":"bce","name":"acme"}]}`) // No terminal: several memberships take the non-interactive refusal — // which names every account. _, err := s.defaultAccount(context.Background()) want := "you belong to several accounts — say which with --account: tom@keymail.dev (bdk), acme (bce)" if err == nil || err.Error() != want { t.Fatalf("several = %v\nwant %q", err, want) } } func TestDefaultAccountChooserPicksSavesAndMemoizes(t *testing.T) { useTempCredentials(t) out := captureStdout(t) forceTerminal(t, true) oldIn := chooserIn chooserIn = strings.NewReader("2\ny\n") t.Cleanup(func() { chooserIn = oldIn }) s, hits := accountsConsole(t, http.StatusOK, `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev"},{"sqid":"bce","name":"acme"}]}`) a, err := s.defaultAccount(context.Background()) if err != nil || a.Seg != "bce" || !a.Exact { t.Fatalf("chooser pick = %+v, %v; want acme's sqid, exact", a, err) } // y saved the pair to ./.carlos/config (cwd is a temp dir here). cfg, err := loadConfig(filepath.Join(".carlos", "config")) if err != nil || cfg.Account != "acme" || cfg.Console != s.console { t.Fatalf("saved config = %+v, %v; want the acme/%s pair", cfg, err, s.console) } if !strings.Contains(out.String(), "saved: acme") { t.Fatalf("the save must be confirmed:\n%s", out.String()) } // Asking again is memoized: same answer, no second fetch, no second // chooser (the input reader is already drained — a re-prompt would // EOF into the refusal). a2, err := s.defaultAccount(context.Background()) if err != nil || a2 != a { t.Fatalf("second resolution = %+v, %v; want the memoized first", a2, err) } if *hits != 1 { t.Fatalf("endpoint hits = %d, want 1 — resolution happens once per invocation", *hits) } } func TestDefaultAccountChooserSaveFailureIsNotFatal(t *testing.T) { useTempCredentials(t) out := captureStdout(t) forceTerminal(t, true) oldIn := chooserIn chooserIn = strings.NewReader("1\ny\n") t.Cleanup(func() { chooserIn = oldIn }) s, _ := accountsConsole(t, http.StatusOK, `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev"},{"sqid":"bce","name":"acme"}]}`) // A read-only working directory: the config walk still reads fine // (nothing exists to read), but MkdirAll(".carlos") cannot create. cwd, err := os.Getwd() if err != nil { t.Fatal(err) } if err := os.Chmod(cwd, 0o555); err != nil { t.Fatal(err) } t.Cleanup(func() { os.Chmod(cwd, 0o755) }) // The human answered; the account is known; the save is bookkeeping. // A read-only checkout must not turn the answer into a dead ship. a, err := s.defaultAccount(context.Background()) if err != nil || a.Seg != "bdk" { t.Fatalf("pick with failing save = %+v, %v; want the pick to stand", a, err) } if !strings.Contains(out.String(), "not saved") { t.Fatalf("a failed save must be said out loud:\n%s", out.String()) } } func TestDefaultAccountOldConsoleFallsBackToOps(t *testing.T) { useTempCredentials(t) captureStdout(t) s, _ := accountsConsole(t, http.StatusNotFound, "404 page not found\n") a, err := s.defaultAccount(context.Background()) if err != nil || a.Seg != "ops" || a.Exact { t.Fatalf("old console = %+v, %v; want the pre-discovery ops default", a, err) } } func TestDefaultAccountOpsFallbackIsSaidOutLoud(t *testing.T) { useTempCredentials(t) out := captureStdout(t) s, _ := accountsConsole(t, http.StatusNotFound, "404 page not found\n") // The fallback must not be silent: an invisible ops followed by the // membership 404 is the three-failures-deep riddle #157 is about. if _, err := s.defaultAccount(context.Background()); err != nil { t.Fatal(err) } if !strings.Contains(out.String(), "account: ops") || !strings.Contains(out.String(), s.console) { t.Fatalf("the ops fallback must name itself and the console:\n%s", out.String()) } } func TestDefaultAccountZeroMemberships(t *testing.T) { useTempCredentials(t) captureStdout(t) s, _ := accountsConsole(t, http.StatusOK, `{"accounts":[]}`) if _, err := s.defaultAccount(context.Background()); err == nil || !strings.Contains(err.Error(), s.console) { t.Fatalf("zero memberships = %v, want an error naming the console", err) } } func TestDefaultAccountAuthErrorSurfaces(t *testing.T) { useTempCredentials(t) captureStdout(t) s, _ := accountsConsole(t, http.StatusUnauthorized, `{"error":"token expired"}`) // A 401 is an answer about the TOKEN, not about memberships — it must // surface, never turn into an ops fallback that then 404s confusingly. if _, err := s.defaultAccount(context.Background()); err == nil || !strings.Contains(err.Error(), "token expired") { t.Fatalf("401 = %v, want the console's own diagnosis", err) } } func TestDefaultAccountConfigDefault(t *testing.T) { useTempCredentials(t) out := captureStdout(t) s, hits := accountsConsole(t, http.StatusOK, `{"accounts":[]}`) // A config account paired with THIS console is honored without asking // the console anything. cfgPath := writeConfig(t, ".", `{"console": "`+s.console+`", "account": "acme"}`) a, err := s.defaultAccount(context.Background()) if err != nil || a.Seg != "acme" || a.Exact { t.Fatalf("config account = %+v, %v; want the name, resolved server-side", a, err) } if *hits != 0 { t.Fatalf("endpoint hits = %d, want 0 — config answered it", *hits) } if !strings.Contains(out.String(), "acme") || !strings.Contains(out.String(), cfgPath) { t.Fatalf("the config-sourced account and its file must be printed; got:\n%s", out.String()) } // A config account with NO console is ignored: the pair is one fact, // and an account key that applied to every console would let a // checked-in file redirect ships across stacks. With zero memberships // behind it, resolution reports those — proving the fall-through. // (Each check below uses a fresh session: resolution memoizes per // invocation, and these are separate invocations.) writeConfig(t, ".", `{"account": "acme"}`) s2 := newTestSession(s.console) if _, err = s2.defaultAccount(context.Background()); err == nil || !strings.Contains(err.Error(), "no account") { t.Fatalf("consoleless config account = %v, want fall-through to memberships", err) } if *hits != 1 { t.Fatalf("endpoint hits = %d, want exactly the fall-through call", *hits) } // Paired with a DIFFERENT console it is ignored the same way. writeConfig(t, ".", `{"console": "https://other.example", "account": "acme"}`) s3 := newTestSession(s.console) if _, err = s3.defaultAccount(context.Background()); err == nil || !strings.Contains(err.Error(), "no account") { t.Fatalf("mismatched config console = %v, want fall-through to memberships", err) } if *hits != 2 { t.Fatalf("endpoint hits = %d, want one per fall-through", *hits) } } func TestConsoleMovedDetection(t *testing.T) { from := "https://dashboard.example" // The real case: a permanent redirect to a new https origin. if e := consoleMoved(from, 308, "https://console.example/api/cli/apps"); e == nil || e.To != "https://console.example" { t.Fatalf("308 = %+v, want console.example", e) } want := "this console has moved: https://dashboard.example -> https://console.example — run: carlos auth login --console https://console.example" if e := consoleMoved(from, 301, "https://console.example/"); e == nil || e.Error() != want { t.Fatalf("moved error = %v\nwant %q", e, want) } // Everything that must NOT read as "moved" — including TEMPORARY // redirects: 302/307 are what SSO walls answer with, and "log in to // accounts.google.com" is not a remedy. for name, tc := range map[string]struct { status int location string }{ "non-redirect status": {404, "https://console.example"}, "302 sso wall": {302, "https://accounts.google.example"}, "307 temporary": {307, "https://console.example"}, "no location": {308, ""}, "relative location": {308, "/somewhere"}, "same origin": {308, from + "/new-path"}, "http off-box target": {308, "http://console.example"}, "garbage location": {308, "::not a url::"}, "schemeless authority": {308, "console.example/x"}, } { if e := consoleMoved(from, tc.status, tc.location); e != nil { t.Errorf("%s = %+v, want nil", name, e) } } } func TestRedirectsAreNeverFollowed(t *testing.T) { useTempCredentials(t) captureStdout(t) forceTerminal(t, false) // A "new" console that would answer if the client followed — its // handler must never run. followed := false target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { followed = true w.Write([]byte(`{}`)) })) t.Cleanup(target.Close) old := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Location", target.URL+r.URL.Path) w.WriteHeader(http.StatusPermanentRedirect) })) t.Cleanup(old.Close) s := newTestSession(old.URL) // GET (the case Go's default policy would have silently followed with // Authorization stripped) and POST both surface the typed error. _, _, err := s.getJSON(context.Background(), "/api/cli/apps") if err == nil || !strings.Contains(err.Error(), "this console has moved: "+old.URL+" -> "+target.URL) { t.Fatalf("GET through a redirect = %v", err) } _, _, err = s.postJSON(context.Background(), "/api/cli/apps/a/b/promote", map[string]string{}) if err == nil || !strings.Contains(err.Error(), "this console has moved") { t.Fatalf("POST through a redirect = %v", err) } if followed { t.Fatal("the client followed a redirect — Authorization would have crossed hosts") } } // resetMovedOffer clears the once-per-process offer guard for one test. func resetMovedOffer(t *testing.T) { t.Helper() consoleMovedOffered = false t.Cleanup(func() { consoleMovedOffered = false }) } func TestConsoleMovedOfferLogsInAndRepointsConfig(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) out := captureStdout(t) forceTerminal(t, true) resetMovedOffer(t) fc := newFakeConsole(t) // the console's NEW home — real device flow old := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Location", fc.ts.URL+r.URL.Path) w.WriteHeader(http.StatusPermanentRedirect) })) t.Cleanup(old.Close) cfgPath := writeConfig(t, ".", `{"console": "`+old.URL+`", "account": "acme"}`) oldIn := chooserIn chooserIn = strings.NewReader("y\ny\n") // log in? yes. repoint config? yes. t.Cleanup(func() { chooserIn = oldIn }) s := newTestSession(old.URL) _, _, err := s.getJSON(context.Background(), "/api/cli/apps") if err == nil || !strings.Contains(err.Error(), "re-run your command") { t.Fatalf("offer accepted = %v, want the re-run message", err) } // The device flow really ran against the new host… f, ferr := loadCredentials() if ferr != nil || f.find(fc.ts.URL) == nil { t.Fatalf("no credential for the new console after the offer: %v", ferr) } // …and the config was repointed, keeping the account key. cfg, cerr := loadConfig(cfgPath) if cerr != nil || cfg.Console != fc.ts.URL || cfg.Account != "acme" { t.Fatalf("config after repoint = %+v, %v; want new console + acme kept", cfg, cerr) } if !strings.Contains(out.String(), "this console has moved") { t.Fatalf("the move must be explained:\n%s", out.String()) } if !strings.Contains(out.String(), "to point at "+fc.ts.URL) { t.Fatalf("the repoint prompt must name the destination:\n%s", out.String()) } // The offer already ran once this process: a second hit — a polling // verb's next tick — gets the typed error and NO prompt, even with // answers queued up. out.Reset() chooserIn = strings.NewReader("y\ny\n") _, _, err = s.getJSON(context.Background(), "/api/cli/apps") if err == nil || !strings.Contains(err.Error(), "this console has moved") { t.Fatalf("second hit = %v, want the plain typed error", err) } if strings.Contains(out.String(), "[y/N]") { t.Fatalf("a second prompt fired:\n%s", out.String()) } } // whoami's memberships line degrades silently even through a MOVED console // — a bonus line must never turn into a prompt (the quietConsoleOffers // suppression). func TestWhoamiStaysSilentThroughAMovedConsole(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) captureStdout(t) forceTerminal(t, true) resetMovedOffer(t) mux := http.NewServeMux() mux.HandleFunc("GET /auth/cli/whoami", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"address": "tom@keymail.dev"}) }) mux.HandleFunc("GET /api/cli/apps", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Location", "https://console.elsewhere.example/api/cli/apps") w.WriteHeader(http.StatusPermanentRedirect) }) ts := httptest.NewServer(mux) t.Cleanup(ts.Close) f, _ := loadCredentials() f.put(credential{Console: ts.URL, Address: "tom@keymail.dev", Token: "tok", CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)}) if err := f.save(); err != nil { t.Fatal(err) } var out bytes.Buffer if err := authWhoami(&out, []string{"-console", ts.URL}); err != nil { t.Fatalf("whoami through a moved memberships endpoint: %v", err) } if strings.Contains(out.String(), "moved") || strings.Contains(out.String(), "[y/N]") || strings.Contains(out.String(), "accounts:") { t.Fatalf("whoami must degrade to identity-only silence:\n%s", out.String()) } if consoleMovedOffered { t.Fatal("whoami consumed the once-per-process offer") } } // resolveMode on a terminal offers to run the login it would otherwise // only name — and then the ORIGINAL command continues (#162). func TestResolveModeOffersLoginAndContinues(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) captureStdout(t) forceTerminal(t, true) fc := newFakeConsole(t) writeConfig(t, ".", `{"console": "`+fc.ts.URL+`"}`) oldIn := chooserIn chooserIn = strings.NewReader("y\n") t.Cleanup(func() { chooserIn = oldIn }) sess, err := resolveMode("") if err != nil || sess == nil { t.Fatalf("resolveMode after offered login = %v, %v", sess, err) } if sess.console != fc.ts.URL || sess.address != "paul@keymail.dev" { t.Fatalf("session = %s as %s, want the fake console as paul", sess.console, sess.address) } } func TestResolveModeOfferDeclinedKeepsTheError(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) captureStdout(t) forceTerminal(t, true) fc := newFakeConsole(t) writeConfig(t, ".", `{"console": "`+fc.ts.URL+`"}`) oldIn := chooserIn chooserIn = strings.NewReader("n\n") t.Cleanup(func() { chooserIn = oldIn }) _, err := resolveMode("") if err == nil || !strings.Contains(err.Error(), "not logged in to "+fc.ts.URL) || !strings.Contains(err.Error(), "carlos auth login --console "+fc.ts.URL) { t.Fatalf("declined offer = %v, want the original refusal verbatim", err) } } func TestResolveModeNonTTYErrorsUnchanged(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) captureStdout(t) forceTerminal(t, false) fc := newFakeConsole(t) writeConfig(t, ".", `{"console": "`+fc.ts.URL+`"}`) // No prompt machinery runs at all: scripts get the string they have // always matched. _, err := resolveMode("") if err == nil || !strings.Contains(err.Error(), "not logged in to "+fc.ts.URL) { t.Fatalf("non-TTY = %v, want today's refusal", err) } // And the nothing-configured case keeps ITS exact wording too. os.RemoveAll(".carlos") if _, err := resolveMode(""); err == nil || !strings.Contains(err.Error(), "no deployment target configured") { t.Fatalf("non-TTY unconfigured = %v", err) } } func TestResolveModeExpiredOffersRelogin(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) out := captureStdout(t) forceTerminal(t, true) fc := newFakeConsole(t) f, err := loadCredentials() if err != nil { t.Fatal(err) } f.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "stale", CreatedAt: time.Now().Add(-48 * time.Hour), ExpiresAt: time.Now().Add(-time.Hour)}) if err := f.save(); err != nil { t.Fatal(err) } oldIn := chooserIn chooserIn = strings.NewReader("y\n") t.Cleanup(func() { chooserIn = oldIn }) // Only one credential and it is expired — pick's "" branch reaches the // expired-first rewrite, which must offer a re-login, and the fresh // credential must carry the NEW token. sess, err := resolveMode("") if err != nil || sess == nil || sess.console != fc.ts.URL { t.Fatalf("expired relogin = %v, %v", sess, err) } if !strings.Contains(out.String(), "has expired") { t.Fatalf("the expiry must be said before the offer:\n%s", out.String()) } } func TestResolveModeUnconfiguredPromptsForURL(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) captureStdout(t) forceTerminal(t, true) fc := newFakeConsole(t) oldIn := chooserIn chooserIn = strings.NewReader(fc.ts.URL + "\n") t.Cleanup(func() { chooserIn = oldIn }) // An empty answer keeps the original two-remedies error. (Checked // FIRST — a successful login below leaves a credential behind, and // resolveMode would simply use it.) chooserIn = strings.NewReader("\n") if _, err := resolveMode(""); err == nil || !strings.Contains(err.Error(), "no deployment target configured") { t.Fatalf("empty URL = %v", err) } // Nothing configured anywhere: the URL prompt IS the consent — typing // one goes straight into the login flow. chooserIn = strings.NewReader(fc.ts.URL + "\n") sess, err := resolveMode("") if err != nil || sess == nil || sess.console != fc.ts.URL { t.Fatalf("URL-prompt login = %v, %v", sess, err) } } func TestDefaultAppSingleSharesTheFetch(t *testing.T) { useTempCredentials(t) out := captureStdout(t) s, hits := accountsConsole(t, http.StatusOK, `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":["taskmonger"]}]}`) // Account then app: one invocation, ONE fetch answering both. acct, err := s.defaultAccount(context.Background()) if err != nil { t.Fatal(err) } app, err := s.defaultApp(context.Background(), acct, false) if err != nil || app != "taskmonger" { t.Fatalf("one app = %q, %v", app, err) } if *hits != 1 { t.Fatalf("endpoint hits = %d, want 1 shared fetch", *hits) } if !strings.Contains(out.String(), "app: taskmonger") { t.Fatalf("the resolved app must be printed:\n%s", out.String()) } } func TestDefaultAppSeveralNonTTY(t *testing.T) { useTempCredentials(t) captureStdout(t) forceTerminal(t, false) s, _ := accountsConsole(t, http.StatusOK, `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":["shop","blog"]}]}`) _, err := s.defaultApp(context.Background(), apiAcct{Seg: "bdk", Exact: true}, false) want := "account tom@keymail.dev has several apps — say which with --app: shop, blog" if err == nil || err.Error() != want { t.Fatalf("several = %v\nwant %q", err, want) } } func TestDefaultAppChooserSaves(t *testing.T) { useTempCredentials(t) captureStdout(t) forceTerminal(t, true) oldIn := chooserIn chooserIn = strings.NewReader("2\ny\n") t.Cleanup(func() { chooserIn = oldIn }) s, _ := accountsConsole(t, http.StatusOK, `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":["shop","blog"]}]}`) app, err := s.defaultApp(context.Background(), apiAcct{Seg: "bdk", Exact: true}, true) if err != nil || app != "blog" { t.Fatalf("chooser pick = %q, %v", app, err) } cfg, err := loadConfig(filepath.Join(".carlos", "config")) if err != nil || cfg.App != "blog" || cfg.Console != s.console || cfg.Account != "tom@keymail.dev" { t.Fatalf("saved config = %+v, %v; want console+account+app together", cfg, err) } } func TestDefaultAppZeroIsTyped(t *testing.T) { useTempCredentials(t) captureStdout(t) s, _ := accountsConsole(t, http.StatusOK, `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":[]}]}`) _, err := s.defaultApp(context.Background(), apiAcct{Seg: "bdk", Exact: true}, false) var na *noAppsError if err == nil || !errors.As(err, &na) || na.Account != "tom@keymail.dev" || !strings.Contains(err.Error(), "carlos apps create") { t.Fatalf("zero apps = %v, want the typed no-apps refusal naming the fix", err) } } func TestDefaultAppConfigPairing(t *testing.T) { useTempCredentials(t) captureStdout(t) s, hits := accountsConsole(t, http.StatusOK, `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":["shop","blog"]}]}`) // Paired with THIS console: honored without any fetch. writeConfig(t, ".", `{"console": "`+s.console+`", "app": "shop"}`) app, err := s.defaultApp(context.Background(), apiAcct{Seg: "bdk", Exact: true}, false) if err != nil || app != "shop" || *hits != 0 { t.Fatalf("config app = %q, %v, hits=%d", app, err, *hits) } // Paired with ANOTHER console: ignored — falls through to the account's // apps, which are ambiguous here, proving the fall-through. writeConfig(t, ".", `{"console": "https://other.example", "app": "shop"}`) forceTerminal(t, false) s2 := newTestSession(s.console) if _, err := s2.defaultApp(context.Background(), apiAcct{Seg: "bdk", Exact: true}, false); err == nil || !strings.Contains(err.Error(), "several apps") { t.Fatalf("mismatched config console = %v, want fall-through", err) } } func TestResolvedAccountExplicitFlagsBypass(t *testing.T) { useTempCredentials(t) captureStdout(t) s, hits := accountsConsole(t, http.StatusOK, `{"accounts":[]}`) raw, rawID := "acme", "" if a := resolvedAccount(s, &raw, &rawID); a.Seg != "acme" || a.Exact { t.Fatalf("--account acme = %+v", a) } raw, rawID = "", "bdk" if a := resolvedAccount(s, &raw, &rawID); a.Seg != "bdk" || !a.Exact { t.Fatalf("--account-id bdk = %+v", a) } if *hits != 0 { t.Fatalf("explicit flags must never consult the endpoint; hits = %d", *hits) } } // TestAPISessionInstanceCreateSendsKind pins the field the static mint // depends on: the door has read a "kind" since the route-records design, but // this client never sent one, so every record it wrote was an instance. // Empty stays absent from the wire — the door's own "empty means instance" // contract, not a value this client invents. func TestAPISessionInstanceCreateSendsKind(t *testing.T) { for _, tc := range []struct { name string kind string wantKind any }{ {"static", "static", "static"}, {"empty is omitted", "", nil}, } { t.Run(tc.name, func(t *testing.T) { var got map[string]any ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&got); err != nil { t.Errorf("decode body: %v", err) } w.WriteHeader(http.StatusCreated) io.WriteString(w, `{"host":"mysite.bab.oncarlos.test","kind":"static","channel":"edge"}`) })) defer ts.Close() rec, err := newTestSession(ts.URL).instanceCreate(context.Background(), apiAcct{Seg: "bab"}, "mysite", "mysite.bab.oncarlos.test", "", "edge", tc.kind, "", nil, nil, nil) if err != nil { t.Fatalf("instanceCreate: %v", err) } if got["kind"] != tc.wantKind { t.Fatalf("wire kind = %#v, want %#v", got["kind"], tc.wantKind) } // The channel is the rail: an omitted one is defaulted to stable // by the door, and no door can change a record's channel after. if got["channel"] != "edge" { t.Fatalf("wire channel = %#v, want an explicit %q", got["channel"], "edge") } if rec.Kind != "static" { t.Fatalf("decoded Kind = %q", rec.Kind) } }) } } // TestAPISessionInstanceSetChannel pins the move client: the channel on the // wire, the record back on a 200, and — the case that must not read as a // failure — a 202 decoded as a PROPOSAL naming where to finish. func TestAPISessionInstanceSetChannel(t *testing.T) { t.Run("lands", func(t *testing.T) { var got map[string]any var gotPath string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path if err := json.NewDecoder(r.Body).Decode(&got); err != nil { t.Errorf("decode body: %v", err) } io.WriteString(w, `{"host":"web.bab.oncarlos.test","channel":"edge","kind":"instance"}`) })) defer ts.Close() rec, err := newTestSession(ts.URL).instanceSetChannel(context.Background(), apiAcct{Seg: "bab"}, "mysite", "web.bab.oncarlos.test", "edge") if err != nil { t.Fatalf("instanceSetChannel: %v", err) } if got["channel"] != "edge" { t.Fatalf("wire channel = %#v", got["channel"]) } if !strings.HasSuffix(gotPath, "/instances/web.bab.oncarlos.test/channel") { t.Fatalf("path = %q, want the per-host channel door", gotPath) } if rec.Channel != "edge" { t.Fatalf("decoded Channel = %q", rec.Channel) } }) t.Run("gated move is a proposal, not a failure", func(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusAccepted) io.WriteString(w, `{"result":"proposed","pending_id":"pc1","needed":2,"confirmed":0,`+ `"console_url":"https://console.example/a/bab/apps/mysite"}`) })) defer ts.Close() _, err := newTestSession(ts.URL).instanceSetChannel(context.Background(), apiAcct{Seg: "bab"}, "mysite", "web.bab.oncarlos.test", "edge") var prop *apiProposedError if !errors.As(err, &prop) { t.Fatalf("err = %v, want an *apiProposedError", err) } if prop.Needed != 2 || prop.ConsoleURL == "" { t.Fatalf("proposal = %+v, want the quorum and where to finish", prop) } }) } // sealSecretAPI binds to the account the console named, and seals the // unbound shape only for a console that named none — an older door, which // would refuse a bound envelope (discussion #220). func TestSealSecretAPIBindsWhenTheConsoleNamesAnAccount(t *testing.T) { privB64, pubB64, err := appconfig.GenerateKey() if err != nil { t.Fatal(err) } priv, _ := appconfig.ParsePrivateKey(privB64, "test") pub, _ := appconfig.ParsePublicKey(pubB64, "test") bound, err := sealSecretAPI(pub, "bab", "keymail", []byte("shh")) if err != nil { t.Fatal(err) } if _, err := appconfig.Open(priv, appconfig.Binding{Account: "bab", App: "keymail"}, bound); err != nil { t.Fatalf("bound envelope does not open for its own app: %v", err) } if _, err := appconfig.Open(priv, appconfig.Binding{Account: "zzz", App: "keymail"}, bound); err == nil { t.Fatal("envelope sealed with a named account opened under another account") } unbound, err := sealSecretAPI(pub, "", "keymail", []byte("shh")) if err != nil { t.Fatal(err) } if err := appconfig.CheckEnvelope(unbound); err != nil { t.Fatal(err) } raw, _ := base64.StdEncoding.DecodeString(unbound) if raw[0] != 0x04 { t.Fatalf("fallback envelope is not the v1 shape an older door accepts (first byte %#x)", raw[0]) } } func TestResolveModeServiceToken(t *testing.T) { useIsolatedConfig(t) clearDeploymentEnv(t) var gotAuth string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"accounts":[]}`)) })) defer ts.Close() // A person's credential for another console is on disk too. The token // wins and the file is not consulted. f, _ := loadCredentials() f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "person", ExpiresAt: time.Now().Add(time.Hour)}) if err := f.save(); err != nil { t.Fatal(err) } t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret") t.Setenv("CARLOS_CONSOLE", ts.URL) sess, err := resolveMode("") if err != nil || sess == nil { t.Fatalf("resolveMode = %v, %v", sess, err) } if sess.address != serviceSessionLabel { t.Fatalf("address = %q", sess.address) } if _, _, err := sess.getJSON(context.Background(), "/api/cli/apps"); err != nil { t.Fatal(err) } if gotAuth != "Bearer svc-secret" { t.Fatalf("Authorization = %q, want the service token", gotAuth) } } func TestResolveModeServiceTokenNeedsAConsole(t *testing.T) { useIsolatedConfig(t) clearDeploymentEnv(t) t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret") if _, err := resolveMode(""); err == nil || !strings.Contains(err.Error(), "CARLOS_CONSOLE") { t.Fatalf("err = %v, want D27", err) } } func TestResolveModeServiceTokenRefusedWithStoreEnv(t *testing.T) { for _, env := range []string{"CARLOS_DEPLOYMENT_BUCKET", "CARLOS_DEPLOYMENT_DIR"} { t.Run(env, func(t *testing.T) { useIsolatedConfig(t) clearDeploymentEnv(t) t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret") t.Setenv("CARLOS_CONSOLE", "https://c.example") t.Setenv(env, t.TempDir()) sess, err := resolveMode("") if err == nil || sess != nil || !strings.Contains(err.Error(), "CARLOS_SERVICE_TOKEN") { t.Fatalf("= %v, %v; want D28", sess, err) } }) } } // A bare store variable still selects direct mode silently when no token is // set: the token arm changes nothing for anyone without one. func TestResolveModeStoreEnvWithoutTokenStillDirect(t *testing.T) { useIsolatedConfig(t) clearDeploymentEnv(t) t.Setenv("CARLOS_DEPLOYMENT_DIR", t.TempDir()) if sess, err := resolveMode(""); sess != nil || err != nil { t.Fatalf("= %v, %v; want (nil, nil)", sess, err) } } // carlosDeployDoors is a COPY of spec §6's list of doors a deploy credential // may reach (R6 included). The root package cannot import the console's test // variable, so the source of truth is deployDoors in // internal/console/deploydoors_test.go: change one, change the other. var carlosDeployDoors = []string{ "GET /api/cli/apps", "GET /api/cli/apps/{account}/{app}/channels", "POST /api/cli/apps/{account}/{app}/releases", "GET /api/cli/apps/{account}/{app}/releases", "POST /api/cli/apps/{account}/{app}/promote", "POST /api/cli/apps/{account}/{app}/rollback", "GET /api/cli/apps/{account}/{app}/tags", "GET /api/cli/apps/{account}/{app}/tags/{version}", "GET /api/cli/apps/{account}/{app}/version-target", "GET /api/cli/apps/{account}/{app}/pipeline", "GET /api/cli/apps/{account}/{app}/pending", "GET /api/cli/apps/{account}/{app}/instances", "GET /api/cli/apps/{account}/{app}/regions", } // doorPattern maps a recorded "METHOD /path" back to the route pattern it // was served by: the account and app segments become {account} and {app}, // and the segment after "tags" becomes {version}. func doorPattern(method, path string) string { if i := strings.IndexByte(path, '?'); i >= 0 { path = path[:i] } seg := strings.Split(strings.Trim(path, "/"), "/") // api, cli, apps, account, app, tail... if len(seg) >= 5 { seg[3], seg[4] = "{account}", "{app}" if len(seg) == 7 && seg[5] == "tags" { seg[6] = "{version}" } } return method + " /" + strings.Join(seg, "/") } func TestCarlosDeployRoutinePathUsesOnlyDeployDoors(t *testing.T) { var mu sync.Mutex var seen []string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() seen = append(seen, r.Method+" "+r.URL.Path) mu.Unlock() w.Header().Set("Content-Type", "application/json") if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/releases") { w.WriteHeader(http.StatusCreated) w.Write([]byte(`{"result":"shipped","version":"1.2.3","artifacts":{}}`)) return } w.Write([]byte(`{"result":"promoted"}`)) })) defer ts.Close() record := func() []string { mu.Lock() defer mu.Unlock() out := seen seen = nil return out } file := filepath.Join(t.TempDir(), "a.bin") if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil { t.Fatal(err) } ctx := context.Background() s := newTestSession(ts.URL) a := apiAcct{Seg: "bab", Exact: true} steps := []struct { name string run func() error }{ {"ship", func() error { _, err := s.ship(ctx, a, "app", "1.2.3", "binary", "", "", map[string]string{"alpha-amd64": file}, nil, false) return err }}, {"promote", func() error { _, err := s.promote(ctx, a, "app", "1.2.3", "canary", false, false); return err }}, {"rollback", func() error { return s.rollback(ctx, a, "app", "canary") }}, {"channels", func() error { _, err := s.channels(ctx, a, "app"); return err }}, {"instanceList", func() error { _, _, _, err := s.instanceList(ctx, a, "app", "", ""); return err }}, {"appRegions", func() error { _, err := s.appRegions(ctx, a, "app"); return err }}, } allowed := map[string]bool{} for _, d := range carlosDeployDoors { allowed[d] = true } for _, st := range steps { if err := st.run(); err != nil { t.Fatalf("%s: %v", st.name, err) } reqs := record() if len(reqs) == 0 { t.Fatalf("%s sent no request", st.name) } for _, r := range reqs { method, path, _ := strings.Cut(r, " ") if p := doorPattern(method, path); !allowed[p] { t.Errorf("%s reached %q (%s), which a deploy credential is not given", st.name, r, p) } } } // The watch's diagnosis reads the logs. That stays a best-effort read a // deploy credential is refused, so it must not be on the list. if _, err := s.logsGet(ctx, a, "app", nil); err != nil { t.Fatalf("logsGet: %v", err) } reqs := record() if len(reqs) != 1 { t.Fatalf("logsGet sent %v", reqs) } method, path, _ := strings.Cut(reqs[0], " ") if !strings.HasSuffix(path, "/logs") { t.Fatalf("logsGet path = %q, want .../logs", path) } if p := doorPattern(method, path); allowed[p] { t.Errorf("%q is on the deploy door list; the logs read must stay refused to a deploy credential", p) } }