| new file mode 100644 |
| index 000000000..485b06046 |
| --- /dev/null |
| +++ b/domains_cli_test.go |
| @@ -0,0 +1,393 @@ |
| +// Tests for the `carlos domains` verb family — the CLI door onto |
| +// internal/console/apidomains.go (Task 10, issue #151). Same two-layer shape |
| +// fleets_cli_test.go established: apiSession-level tests drive the three new |
| +// client methods (apimode.go) directly against an httptest fake console |
| +// (path, body, wire-shape round trips), and RunDomains-level tests drive the |
| +// full verb (flag parsing, printing) against the same kind of fake console, |
| +// credentials written straight to disk. Usage-error paths (log.Fatal, would |
| +// kill an in-process test binary) run as a real subprocess, carlosTestEnv + |
| +// scratch cmd.Dir per CLAUDE.md's rule. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// ---- apiSession-level: path + body + wire-shape round trips --------------- |
| + |
| +// TestAPISessionDomainAttach: domainAttach POSTs {host,route} to the app's |
| +// domains door and decodes created + the route the server resolved. |
| +func TestAPISessionDomainAttach(t *testing.T) { |
| + var gotPath, gotMethod string |
| + var gotBody struct{ Host, Route string } |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath, gotMethod = r.URL.Path, r.Method |
| + json.NewDecoder(r.Body).Decode(&gotBody) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "host": gotBody.Host, "route": gotBody.Route, "created": true, |
| + }) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + |
| + created, route, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", |
| + "tickets.example.com", "slug.go.tito.io") |
| + if err != nil { |
| + t.Fatalf("domainAttach: %v", err) |
| + } |
| + if gotPath != "/api/cli/apps/bab/tito/domains" || gotMethod != http.MethodPost { |
| + t.Fatalf("request = %s %s, want POST .../tito/domains", gotMethod, gotPath) |
| + } |
| + if gotBody.Host != "tickets.example.com" || gotBody.Route != "slug.go.tito.io" { |
| + t.Fatalf("body = %+v", gotBody) |
| + } |
| + if !created || route != "slug.go.tito.io" { |
| + t.Fatalf("domainAttach = created=%v route=%q", created, route) |
| + } |
| +} |
| + |
| +// TestAPISessionDomainAttachNoOp: created=false round-trips (the re-attach, |
| +// nothing-changed case), and the server's resolved route still comes back |
| +// even when the caller sent none. |
| +func TestAPISessionDomainAttachNoOp(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "host": "tickets.example.com", "route": "default.go.tito.io", "created": false, |
| + }) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + |
| + created, route, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", |
| + "tickets.example.com", "") |
| + if err != nil { |
| + t.Fatalf("domainAttach: %v", err) |
| + } |
| + if created || route != "default.go.tito.io" { |
| + t.Fatalf("domainAttach = created=%v route=%q, want created=false route=default.go.tito.io", created, route) |
| + } |
| +} |
| + |
| +// TestAPISessionDomainAttachConflict: a 409 surfaces the server's |
| +// "hostname unavailable" message verbatim, apiErrorFromBody's json-first |
| +// decode doing the work — no special-casing needed on this side. |
| +func TestAPISessionDomainAttachConflict(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]any{"error": "hostname unavailable"}) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + |
| + _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "taken.example.com", "") |
| + if err == nil || err.Error() != "hostname unavailable" { |
| + t.Fatalf("domainAttach against 409 = %v, want exactly \"hostname unavailable\"", err) |
| + } |
| +} |
| + |
| +// TestAPISessionDomainDetach: domainDetach DELETEs with the host |
| +// path-escaped and decodes removed. |
| +func TestAPISessionDomainDetach(t *testing.T) { |
| + var gotPath, gotMethod string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath, gotMethod = r.URL.EscapedPath(), r.Method |
| + json.NewEncoder(w).Encode(map[string]any{"host": "tickets.example.com", "removed": true}) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + |
| + removed, err := s.domainDetach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com") |
| + if err != nil { |
| + t.Fatalf("domainDetach: %v", err) |
| + } |
| + if gotMethod != http.MethodDelete { |
| + t.Fatalf("method = %s, want DELETE", gotMethod) |
| + } |
| + if gotPath != "/api/cli/apps/bab/tito/domains/tickets.example.com" { |
| + t.Fatalf("path = %s, want the app's domains door with the host appended", gotPath) |
| + } |
| + if !removed { |
| + t.Fatal("removed = false, want true") |
| + } |
| +} |
| + |
| +// TestAPISessionDomainDetachWasNotAttached: removed=false round-trips |
| +// unchanged — the door's own three-ways-the-same neutrality (spec §5). |
| +func TestAPISessionDomainDetachWasNotAttached(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{"host": "ghost.example.com", "removed": false}) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + |
| + removed, err := s.domainDetach(context.Background(), apiAcct{Seg: "bab"}, "tito", "ghost.example.com") |
| + if err != nil { |
| + t.Fatalf("domainDetach: %v", err) |
| + } |
| + if removed { |
| + t.Fatal("removed = true, want false") |
| + } |
| +} |
| + |
| +// TestAPISessionDomainList: domainList GETs the domains door and decodes the |
| +// "domains" wrapper, tolerating rows that omit dns_status/cert_expires_at |
| +// (a claim no live box has materialized yet — apidomains.go's "pending"). |
| +func TestAPISessionDomainList(t *testing.T) { |
| + var gotPath, gotMethod string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath, gotMethod = r.URL.Path, r.Method |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "domains": []map[string]any{ |
| + {"host": "tickets.example.com", "route": "slug.go.tito.io", "status": "live", |
| + "dns_status": "pointing", "cert_expires_at": 1799999999}, |
| + {"host": "new.example.com", "route": "slug.go.tito.io", "status": "pending"}, |
| + }, |
| + }) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + |
| + recs, err := s.domainList(context.Background(), apiAcct{Seg: "bab"}, "tito") |
| + if err != nil { |
| + t.Fatalf("domainList: %v", err) |
| + } |
| + if gotPath != "/api/cli/apps/bab/tito/domains" || gotMethod != http.MethodGet { |
| + t.Fatalf("request = %s %s, want GET .../tito/domains", gotMethod, gotPath) |
| + } |
| + if len(recs) != 2 { |
| + t.Fatalf("domainList returned %d records, want 2", len(recs)) |
| + } |
| + if recs[0].Host != "tickets.example.com" || recs[0].Route != "slug.go.tito.io" || |
| + recs[0].Status != "live" || recs[0].DNSStatus != "pointing" || recs[0].CertExpiresAt != 1799999999 { |
| + t.Fatalf("recs[0] = %+v", recs[0]) |
| + } |
| + if recs[1].Host != "new.example.com" || recs[1].Status != "pending" || |
| + recs[1].DNSStatus != "" || recs[1].CertExpiresAt != 0 { |
| + t.Fatalf("recs[1] = %+v, want empty dns_status and zero cert_expires_at", recs[1]) |
| + } |
| +} |
| + |
| +// TestAPISessionDomainDoorsSurfaceServerErrors: a non-2xx from any of the |
| +// three domain doors surfaces an error rather than a swallowed one. |
| +func TestAPISessionDomainDoorsSurfaceServerErrors(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusNotFound) |
| + w.Write([]byte("404 page not found\n")) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + acct := apiAcct{Seg: "bab"} |
| + |
| + if _, _, err := s.domainAttach(context.Background(), acct, "ghost", "x.example.com", ""); err == nil { |
| + t.Fatal("domainAttach against a 404 must error") |
| + } |
| + if _, err := s.domainDetach(context.Background(), acct, "ghost", "x.example.com"); err == nil { |
| + t.Fatal("domainDetach against a 404 must error") |
| + } |
| + if _, err := s.domainList(context.Background(), acct, "ghost"); err == nil { |
| + t.Fatal("domainList against a 404 must error") |
| + } |
| +} |
| + |
| +// ---- RunDomains-level: full verb, flag parsing + printing ----------------- |
| + |
| +// fakeDomainsConsole is a minimal in-process console answering every |
| +// domains door with fixed, easily-asserted bodies. |
| +type fakeDomainsConsole struct { |
| + ts *httptest.Server |
| + lastAttachPath string |
| + lastAttachBody struct{ Host, Route string } |
| + attachCreated bool |
| + lastDetachPath string |
| + detachRemoved bool |
| +} |
| + |
| +func newFakeDomainsConsole(t *testing.T) *fakeDomainsConsole { |
| + t.Helper() |
| + fc := &fakeDomainsConsole{attachCreated: true, detachRemoved: true} |
| + fc.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + switch { |
| + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/domains"): |
| + fc.lastAttachPath = r.URL.Path |
| + json.NewDecoder(r.Body).Decode(&fc.lastAttachBody) |
| + route := fc.lastAttachBody.Route |
| + if route == "" { |
| + route = "default.go.tito.io" |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "host": fc.lastAttachBody.Host, "route": route, "created": fc.attachCreated, |
| + }) |
| + case r.Method == http.MethodDelete: |
| + fc.lastDetachPath = r.URL.Path |
| + json.NewEncoder(w).Encode(map[string]any{"host": "x", "removed": fc.detachRemoved}) |
| + case r.Method == http.MethodGet: |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "domains": []map[string]any{ |
| + {"host": "tickets.example.com", "route": "slug.go.tito.io", "status": "live", |
| + "dns_status": "pointing", "cert_expires_at": 1799999999}, |
| + {"host": "new.example.com", "route": "slug.go.tito.io", "status": "pending"}, |
| + }, |
| + }) |
| + default: |
| + w.WriteHeader(http.StatusNotFound) |
| + } |
| + })) |
| + t.Cleanup(fc.ts.Close) |
| + return fc |
| +} |
| + |
| +// domainsCLIFixture wires up a fake console plus a credential pointed at it, |
| +// entirely in-process — the same shape fleetsCLIFixture uses. |
| +func domainsCLIFixture(t *testing.T) *fakeDomainsConsole { |
| + t.Helper() |
| + home := t.TempDir() |
| + t.Setenv("HOME", home) |
| + t.Setenv("CARLOS_CONSOLE", "") |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", "") |
| + t.Chdir(t.TempDir()) |
| + creds := filepath.Join(home, "carlos-credentials") |
| + t.Setenv("CARLOS_CREDENTIALS", creds) |
| + fc := newFakeDomainsConsole(t) |
| + writeTestCredential(t, creds, fc.ts.URL) |
| + return fc |
| +} |
| + |
| +func TestRunDomainsAttachPrintsAttached(t *testing.T) { |
| + fc := domainsCLIFixture(t) |
| + fc.attachCreated = true |
| + buf := captureStdout(t) |
| + |
| + runDomains([]string{"attach", "-app", "tito", "-route", "slug.go.tito.io", "tickets.example.com"}) |
| + |
| + if fc.lastAttachBody.Host != "tickets.example.com" || fc.lastAttachBody.Route != "slug.go.tito.io" { |
| + t.Fatalf("console saw body %+v", fc.lastAttachBody) |
| + } |
| + if !strings.Contains(buf.String(), "attached tickets.example.com -> slug.go.tito.io") { |
| + t.Fatalf("stdout = %q, want the attached confirmation", buf.String()) |
| + } |
| +} |
| + |
| +func TestRunDomainsAttachPrintsAlreadyAttachedOnNoOp(t *testing.T) { |
| + fc := domainsCLIFixture(t) |
| + fc.attachCreated = false |
| + buf := captureStdout(t) |
| + |
| + runDomains([]string{"attach", "-app", "tito", "tickets.example.com"}) |
| + |
| + if !strings.Contains(buf.String(), "already attached") { |
| + t.Fatalf("stdout = %q, want the already-attached confirmation", buf.String()) |
| + } |
| +} |
| + |
| +func TestRunDomainsDetachPrintsDetached(t *testing.T) { |
| + fc := domainsCLIFixture(t) |
| + fc.detachRemoved = true |
| + buf := captureStdout(t) |
| + |
| + runDomains([]string{"detach", "-app", "tito", "tickets.example.com"}) |
| + |
| + if !strings.HasSuffix(fc.lastDetachPath, "/domains/tickets.example.com") { |
| + t.Fatalf("detach path = %s, want it to end in the escaped host", fc.lastDetachPath) |
| + } |
| + if !strings.Contains(buf.String(), "detached tickets.example.com") { |
| + t.Fatalf("stdout = %q, want the detached confirmation", buf.String()) |
| + } |
| +} |
| + |
| +func TestRunDomainsDetachPrintsWasNotAttached(t *testing.T) { |
| + fc := domainsCLIFixture(t) |
| + fc.detachRemoved = false |
| + buf := captureStdout(t) |
| + |
| + runDomains([]string{"detach", "-app", "tito", "ghost.example.com"}) |
| + |
| + if !strings.Contains(buf.String(), "was not attached") { |
| + t.Fatalf("stdout = %q, want the was-not-attached confirmation", buf.String()) |
| + } |
| +} |
| + |
| +// TestRunDomainsListPrintsTable: the list table carries every column and |
| +// falls back to "-" for the row with no dns_status/cert_expires_at. |
| +func TestRunDomainsListPrintsTable(t *testing.T) { |
| + domainsCLIFixture(t) |
| + buf := captureStdout(t) |
| + |
| + runDomains([]string{"list", "-app", "tito"}) |
| + |
| + out := buf.String() |
| + for _, want := range []string{ |
| + "HOST", "ROUTE", "STATUS", "DNS", "CERT-EXPIRES", |
| + "tickets.example.com", "slug.go.tito.io", "live", "pointing", "2027-01-15", |
| + "new.example.com", "pending", |
| + } { |
| + if !strings.Contains(out, want) { |
| + t.Fatalf("stdout missing %q:\n%s", want, out) |
| + } |
| + } |
| + // The pending row's dns/cert columns must read "-", not a stray zero or |
| + // blank cell that would misalign the table. |
| + lines := strings.Split(out, "\n") |
| + var pendingLine string |
| + for _, l := range lines { |
| + if strings.Contains(l, "new.example.com") { |
| + pendingLine = l |
| + } |
| + } |
| + if pendingLine == "" || !strings.Contains(pendingLine, "-") { |
| + t.Fatalf("pending row = %q, want a \"-\" placeholder for the absent columns", pendingLine) |
| + } |
| +} |
| + |
| +// ---- subprocess: the usage-error / bad-argument paths --------------------- |
| + |
| +// TestDomainsCLIUsageErrors drives every domains verb with missing or |
| +// malshaped arguments as a real subprocess (buildCarlos) — the only way to |
| +// observe a log.Fatal/os.Exit refusal without killing the test binary. |
| +// carlosTestEnv masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE and cmd.Dir is |
| +// a scratch dir (CLAUDE.md's rule), so a case that DID try to dial out would |
| +// fail on "not logged in", not silently succeed. |
| +func TestDomainsCLIUsageErrors(t *testing.T) { |
| + bin := buildCarlos(t) |
| + scratch := t.TempDir() |
| + env := carlosTestEnv(t) |
| + |
| + run := func(args ...string) (string, error) { |
| + cmd := exec.Command(bin, args...) |
| + cmd.Env = env |
| + cmd.Dir = scratch |
| + out, err := cmd.CombinedOutput() |
| + return string(out), err |
| + } |
| + |
| + for _, tc := range []struct { |
| + name string |
| + args []string |
| + want string |
| + }{ |
| + {"domains no args", []string{"domains"}, "usage: carlos domains"}, |
| + {"domains bogus verb", []string{"domains", "bogus"}, "usage: carlos domains"}, |
| + {"attach no app", []string{"domains", "attach", "tickets.example.com"}, "usage: carlos domains attach"}, |
| + {"attach no positional host", []string{"domains", "attach", "-app", "tito"}, "usage: carlos domains attach"}, |
| + {"detach no app", []string{"domains", "detach", "tickets.example.com"}, "usage: carlos domains detach"}, |
| + {"detach no positional host", []string{"domains", "detach", "-app", "tito"}, "usage: carlos domains detach"}, |
| + {"list no app", []string{"domains", "list"}, "usage: carlos domains list"}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + out, err := run(tc.args...) |
| + if err == nil { |
| + t.Fatalf("carlos %s exited 0, want a nonzero (usage) exit:\n%s", strings.Join(tc.args, " "), out) |
| + } |
| + if !strings.Contains(out, tc.want) { |
| + t.Fatalf("carlos %s output missing %q:\n%s", strings.Join(tc.args, " "), tc.want, out) |
| + } |
| + }) |
| + } |
| +} |