// Tests for `carlos pipeline` (Task 10, release pipelines v2) and the // promote/rollback proposed-response handling it shares with promote and // rollback. In-process, stub-console pattern (TestPromoteVerbAPIMode, // apimode_test.go) throughout: useTempCredentials/clearDeploymentEnv/ // requireNoRegistryTouch/loginTo/captureStdout, an httptest server standing // in for the console. package main import ( "encoding/json" "net/http" "net/http/httptest" "reflect" "strings" "sync/atomic" "testing" ) // TestPipelineShowVerbAPIMode pins `carlos pipeline show`'s read path: it // GETs the pipeline and the pending list, and renders a multi-channel // pipeline as a table naming each channel's order, bake and rules, with any // pending change listed beneath. func TestPipelineShowVerbAPIMode(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) var gotPipelinePath, gotPendingPath string mux := http.NewServeMux() mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { gotPipelinePath = r.URL.Path json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{ "channels": []map[string]any{ {"name": "edge"}, {"name": "production", "bake": "24h", "rules": map[string]any{"passkey": true, "change_approvals": 1}}, }, }}) }) mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pending", func(w http.ResponseWriter, r *http.Request) { gotPendingPath = r.URL.Path json.NewEncoder(w).Encode(map[string]any{"pending": []map[string]any{ {"id": "pc1", "kind": "policy", "summary": "change the pipeline to edge → production", "confirmed": 0, "needed": 1}, }}) }) ts := httptest.NewServer(mux) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runPipeline([]string{"show", "-app", "titogo", "-account", apiTestAccount}) out := buf.String() if gotPipelinePath != "/api/cli/apps/"+apiTestAccount+"/titogo/pipeline" { t.Fatalf("pipeline GET path = %q", gotPipelinePath) } if gotPendingPath != "/api/cli/apps/"+apiTestAccount+"/titogo/pending" { t.Fatalf("pending GET path = %q", gotPendingPath) } if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) { t.Fatalf("stdout = %q, want it to contain %q", out, want) } edgeAt := strings.Index(out, "edge") prodAt := strings.Index(out, "production") if edgeAt < 0 || prodAt < 0 || edgeAt > prodAt { t.Fatalf("stdout = %q, want edge listed before production", out) } if !strings.Contains(out, "24h") { t.Fatalf("stdout = %q, want the bake window", out) } if !strings.Contains(out, "passkey") || !strings.Contains(out, "change:1") { t.Fatalf("stdout = %q, want the rules rendered", out) } if !strings.Contains(out, "change the pipeline to edge → production") || !strings.Contains(out, "0/1") || !strings.Contains(out, "pc1") { t.Fatalf("stdout = %q, want the pending change listed with its progress and id", out) } checkNoRegistry() } // TestPipelineShowOneChannelNoLadderCeremony pins controller ruling 5: a // one-channel pipeline (here, the "no pipeline.json" default) prints a // single line, not a one-row table, with a hint that init grows it. func TestPipelineShowOneChannelNoLadderCeremony(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) mux := http.NewServeMux() mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"pipeline": nil}) }) mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pending", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"pending": []map[string]any{}}) }) ts := httptest.NewServer(mux) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runPipeline([]string{"show", "-app", "titogo", "-account", apiTestAccount}) out := buf.String() if !strings.Contains(out, "edge") { t.Fatalf("stdout = %q, want the single channel named", out) } if !strings.Contains(out, "carlos pipeline init") { t.Fatalf("stdout = %q, want the init hint", out) } if strings.Contains(out, "ORDER") || strings.Contains(out, "RULES") { t.Fatalf("stdout = %q, must not render ladder-table ceremony for one channel", out) } checkNoRegistry() } // TestPipelineInitTemplateAPIMode pins the edge-production template's exact // wire shape (controller ruling 3): a template that sets a rule also sets // change_approvals, and promote_approvals (never set by either template) is // omitted entirely rather than sent as 0. func TestPipelineInitTemplateAPIMode(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) var reqCount int32 var gotBody []byte mux := http.NewServeMux() mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&reqCount, 1) var buf [4096]byte n, _ := r.Body.Read(buf[:]) gotBody = buf[:n] json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{ "channels": []map[string]any{ {"name": "edge"}, {"name": "production", "bake": "24h", "rules": map[string]any{"passkey": true, "change_approvals": 1}}, }, }}) }) ts := httptest.NewServer(mux) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runPipeline([]string{"init", "-template", "edge-production", "-app", "titogo", "-account", apiTestAccount}) if got := atomic.LoadInt32(&reqCount); got != 1 { t.Fatalf("server got %d PUT requests, want 1", got) } var sent struct { Channels []map[string]any `json:"channels"` } if err := json.Unmarshal(gotBody, &sent); err != nil { t.Fatalf("PUT body unreadable: %v (%s)", err, gotBody) } want := []map[string]any{ {"name": "edge"}, {"name": "production", "bake": "24h", "rules": map[string]any{"passkey": true, "change_approvals": float64(1)}}, } if !reflect.DeepEqual(sent.Channels, want) { t.Fatalf("PUT channels = %#v, want %#v", sent.Channels, want) } if !strings.Contains(buf.String(), "titogo") { t.Fatalf("stdout = %q, want it to name the app", buf.String()) } checkNoRegistry() } // TestPipelineFullLadderTemplateAPIMode pins the second template's exact // shape alongside the first, per controller ruling 3. func TestPipelineFullLadderTemplateAPIMode(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) var gotBody []byte mux := http.NewServeMux() mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { var buf [4096]byte n, _ := r.Body.Read(buf[:]) gotBody = buf[:n] json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{"channels": []map[string]any{}}}) }) ts := httptest.NewServer(mux) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") captureStdout(t) runPipeline([]string{"init", "-template", "full-ladder", "-app", "titogo", "-account", apiTestAccount}) var sent struct { Channels []map[string]any `json:"channels"` } if err := json.Unmarshal(gotBody, &sent); err != nil { t.Fatalf("PUT body unreadable: %v (%s)", err, gotBody) } want := []map[string]any{ {"name": "edge"}, {"name": "beta", "bake": "24h"}, {"name": "stable", "bake": "72h", "rules": map[string]any{"passkey": true, "change_approvals": float64(1)}}, } if !reflect.DeepEqual(sent.Channels, want) { t.Fatalf("PUT channels = %#v, want %#v", sent.Channels, want) } checkNoRegistry() } // TestPipelineSetComposesFullList pins set's compose-then-PUT contract: it // GETs the current list, mutates ONLY the named channel's ONE named flag // (-bake), and PUTs every channel back — including the untouched one // verbatim, and the edited one's OTHER rules (passkey, change_approvals) // preserved rather than clobbered by the flags this invocation didn't name. func TestPipelineSetComposesFullList(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) var gotBody []byte var getCount, putCount int32 mux := http.NewServeMux() mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&getCount, 1) json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{ "channels": []map[string]any{ {"name": "edge"}, {"name": "production", "bake": "24h", "rules": map[string]any{"passkey": true, "change_approvals": 1}}, }, }}) }) mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&putCount, 1) var buf [4096]byte n, _ := r.Body.Read(buf[:]) gotBody = buf[:n] json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{"channels": []map[string]any{}}}) }) ts := httptest.NewServer(mux) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") captureStdout(t) runPipeline([]string{"set", "-channel", "production", "-bake", "48h", "-app", "titogo", "-account", apiTestAccount}) if got := atomic.LoadInt32(&getCount); got != 1 { t.Fatalf("server got %d GET requests, want 1", got) } if got := atomic.LoadInt32(&putCount); got != 1 { t.Fatalf("server got %d PUT requests, want 1", got) } var sent struct { Channels []map[string]any `json:"channels"` } if err := json.Unmarshal(gotBody, &sent); err != nil { t.Fatalf("PUT body unreadable: %v (%s)", err, gotBody) } want := []map[string]any{ {"name": "edge"}, {"name": "production", "bake": "48h", "rules": map[string]any{"passkey": true, "change_approvals": float64(1)}}, } if !reflect.DeepEqual(sent.Channels, want) { t.Fatalf("PUT channels = %#v, want %#v (bake changed, everything else preserved)", sent.Channels, want) } checkNoRegistry() } // TestPipelineAddInsertsAfter pins add's -after insertion and its own // compose-then-PUT contract: it GETs the current list and PUTs it back with // the new channel spliced in immediately after the named one, the existing // channels otherwise untouched. func TestPipelineAddInsertsAfter(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) var gotBody []byte mux := http.NewServeMux() mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{ "channels": []map[string]any{{"name": "edge"}, {"name": "stable", "bake": "72h"}}, }}) }) mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { var buf [4096]byte n, _ := r.Body.Read(buf[:]) gotBody = buf[:n] json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{"channels": []map[string]any{}}}) }) ts := httptest.NewServer(mux) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") captureStdout(t) runPipeline([]string{"add", "-channel", "beta", "-after", "edge", "-bake", "24h", "-app", "titogo", "-account", apiTestAccount}) var sent struct { Channels []map[string]any `json:"channels"` } if err := json.Unmarshal(gotBody, &sent); err != nil { t.Fatalf("PUT body unreadable: %v (%s)", err, gotBody) } want := []map[string]any{ {"name": "edge"}, {"name": "beta", "bake": "24h"}, {"name": "stable", "bake": "72h"}, } if !reflect.DeepEqual(sent.Channels, want) { t.Fatalf("PUT channels = %#v, want %#v", sent.Channels, want) } checkNoRegistry() } // TestPipelineRemoveDropsChannel pins remove's compose-then-PUT contract: // it GETs the current list and PUTs it back without the named channel, the // rest untouched and in the same order. func TestPipelineRemoveDropsChannel(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) var gotBody []byte mux := http.NewServeMux() mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{ "channels": []map[string]any{{"name": "edge"}, {"name": "beta", "bake": "24h"}, {"name": "stable", "bake": "72h"}}, }}) }) mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { var buf [4096]byte n, _ := r.Body.Read(buf[:]) gotBody = buf[:n] json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{"channels": []map[string]any{}}}) }) ts := httptest.NewServer(mux) defer ts.Close() loginTo(t, ts.URL, "vicky@keymail.dev") captureStdout(t) runPipeline([]string{"remove", "-channel", "beta", "-app", "titogo", "-account", apiTestAccount}) var sent struct { Channels []map[string]any `json:"channels"` } if err := json.Unmarshal(gotBody, &sent); err != nil { t.Fatalf("PUT body unreadable: %v (%s)", err, gotBody) } want := []map[string]any{ {"name": "edge"}, {"name": "stable", "bake": "72h"}, } if !reflect.DeepEqual(sent.Channels, want) { t.Fatalf("PUT channels = %#v, want %#v", sent.Channels, want) } checkNoRegistry() } // TestPipelinePutProposedPrintsConsoleURL pins a guarded pipeline PUT's own // 202 handling — the same proposed-response contract as promote/rollback, // exercised through `carlos pipeline set` this time. func TestPipelinePutProposedPrintsConsoleURL(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) consoleURL := "" mux := http.NewServeMux() mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{ "channels": []map[string]any{ {"name": "edge"}, {"name": "production", "bake": "24h", "rules": map[string]any{"passkey": true, "change_approvals": 1}}, }, }}) }) mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusAccepted) json.NewEncoder(w).Encode(map[string]any{ "result": "proposed", "pending_id": "pc-99", "needed": 1, "confirmed": 0, "console_url": consoleURL, }) }) ts := httptest.NewServer(mux) defer ts.Close() consoleURL = ts.URL + "/a/" + apiTestAccount + "/apps/titogo" loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runPipeline([]string{"set", "-channel", "production", "-bake", "48h", "-app", "titogo", "-account", apiTestAccount}) out := buf.String() if !strings.Contains(out, "proposed") || !strings.Contains(out, consoleURL) || !strings.Contains(out, "pc-99") { t.Fatalf("stdout = %q, want proposed + console URL + pending id", out) } checkNoRegistry() } // TestPromoteProposedPrintsConsoleURL pins runPromote's handling of a 202: a // guarded channel's PROMOTE_APPROVALS rule turns the request into a // proposal, and the CLI must say so — naming how many confirmations it // needs and where to finish — rather than reporting the generic "unexpected // response (status 202)" (the CLI-lies-about-its-outcome bug this task // exists to close). func TestPromoteProposedPrintsConsoleURL(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) consoleURL := "" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusAccepted) json.NewEncoder(w).Encode(map[string]any{ "result": "proposed", "pending_id": "pc-42", "needed": 2, "confirmed": 0, "console_url": consoleURL, }) })) defer ts.Close() consoleURL = ts.URL + "/a/" + apiTestAccount + "/apps/titogo" loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runPromote([]string{"-app", "titogo", "-account", apiTestAccount, "abc1234", "production"}) out := buf.String() if !strings.Contains(out, "proposed") { t.Fatalf("stdout = %q, want it to say proposed", out) } if !strings.Contains(out, "2") { t.Fatalf("stdout = %q, want the confirmations-needed count (2)", out) } if !strings.Contains(out, consoleURL) { t.Fatalf("stdout = %q, want the console URL %q", out, consoleURL) } checkNoRegistry() } // TestRollbackProposedPrintsConsoleURL is TestPromoteProposedPrintsConsoleURL's // twin for `carlos rollback` (controller ruling 1): Task 8 added the 202 on // the server side, but runRollback still reported the generic "unexpected // response (status 202)" until this task extended it exactly as promote was // extended. func TestRollbackProposedPrintsConsoleURL(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) consoleURL := "" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusAccepted) json.NewEncoder(w).Encode(map[string]any{ "result": "proposed", "pending_id": "pc-43", "needed": 1, "confirmed": 0, "console_url": consoleURL, }) })) defer ts.Close() consoleURL = ts.URL + "/a/" + apiTestAccount + "/apps/titogo" loginTo(t, ts.URL, "vicky@keymail.dev") buf := captureStdout(t) runRollback([]string{"-app", "titogo", "-account", apiTestAccount, "production"}) out := buf.String() if !strings.Contains(out, "proposed") { t.Fatalf("stdout = %q, want it to say proposed", out) } if !strings.Contains(out, "1") { t.Fatalf("stdout = %q, want the confirmations-needed count (1)", out) } if !strings.Contains(out, consoleURL) { t.Fatalf("stdout = %q, want the console URL %q", out, consoleURL) } if !strings.Contains(out, "pc-43") { t.Fatalf("stdout = %q, want the pending id", out) } checkNoRegistry() } // TestPromoteHotfixSentToAPI pins controller ruling 2: -hotfix is no longer // refused in API mode (cli.go used to log.Fatal before ever dialing out, // claiming the console "has no hotfix field at all" — now false), and the // field actually reaches the wire. func TestPromoteHotfixSentToAPI(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) var req struct { Version string `json:"version"` Channel string `json:"channel"` Hotfix bool `json:"hotfix"` } json.NewDecoder(r.Body).Decode(&req) if !req.Hotfix { t.Errorf("hotfix = %v, want true", req.Hotfix) } if req.Version != "abc1234" || req.Channel != "stable" { 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, "-hotfix", "abc1234", "stable"}) if got := atomic.LoadInt32(&reqCount); got != 1 { t.Fatalf("server got %d requests, want 1", got) } if want := "promoted titogo abc1234 -> stable"; !strings.Contains(buf.String(), want) { t.Fatalf("stdout = %q, want %q", buf.String(), want) } checkNoRegistry() } // TestPipelineDirectModeRefused pins the direct/bucket-mode refusal: there // is no bucket-side pipeline editor, so a `carlos pipeline` verb must name // the console requirement rather than silently doing nothing or panicking // on a nil session. Exercised at the resolveMode level, mirroring how // runPromote's stable refusal is pinned in auth_e2e_test.go — runPipeline // itself calls log.Fatal (os.Exit) on this path, which an in-process test // cannot survive. func TestPipelineDirectModeRefused(t *testing.T) { dir := t.TempDir() t.Setenv("CARLOS_DEPLOYMENT_DIR", dir) t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") t.Setenv("CARLOS_CONSOLE", "") sess, err := resolveMode("") if err != nil { t.Fatalf("resolveMode: %v", err) } if sess != nil { t.Fatal("want direct mode (nil session) with CARLOS_DEPLOYMENT_DIR set") } }