| index 5fdf686d3..95fe4eaa8 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -5,6 +5,7 @@ |
| package main |
| |
| import ( |
| + "bytes" |
| "context" |
| "encoding/json" |
| "io" |
| @@ -13,10 +14,26 @@ import ( |
| "os" |
| "path/filepath" |
| "strings" |
| + "sync/atomic" |
| "testing" |
| "time" |
| + |
| + "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 |
| @@ -544,3 +561,318 @@ func TestAPISessionChannels(t *testing.T) { |
| t.Fatalf("row 1 = %+v", chans[1]) |
| } |
| } |
| + |
| +// ---- 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) |
| + } |
| +} |
| + |
| +// 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 <origin> as |
| +// <address>" 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) |
| + |
| + var reqCount int32 |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("POST /api/cli/apps/acct/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", "acct", "-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) |
| + } |
| +} |
| + |
| +// 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) |
| + |
| + 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", "acct", "-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) |
| + } |
| +} |
| + |
| +// 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) |
| + |
| + 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/acct/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", "acct", "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) |
| + } |
| +} |
| + |
| +// 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) |
| + |
| + 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", "acct", "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) |
| + } |
| +} |
| + |
| +// 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). |
| +func TestChannelsVerbAPIMode(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/acct/titogo/channels" { |
| + t.Errorf("path = %s", r.URL.Path) |
| + } |
| + 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() |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runChannels([]string{"-app", "titogo", "-account", "acct"}) |
| + |
| + 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") { |
| + 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) |
| + } |
| +} |
| + |
| +// TestRollbackVerbAPIMode pins runRollback's API-mode wiring. |
| +func TestRollbackVerbAPIMode(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) |
| + if r.URL.Path != "/api/cli/apps/acct/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", "acct", "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) |
| + } |
| +} |
| + |
| +// 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() |
| + 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) |
| + } |
| +} |