// 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 AGENTS.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", "", "", nil, nil) 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", "", "", "", nil, nil) 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", "", "", "", nil, nil) if err == nil || err.Error() != "hostname unavailable" { t.Fatalf("domainAttach against 409 = %v, want exactly \"hostname unavailable\"", err) } } // TestAPISessionDomainAttachWildcardRefusal: a 422 (the wildcard/catchall // gate) surfaces its own message verbatim too — the CNAME record the caller // needs to create, exactly as the door wrote it, not summarized. func TestAPISessionDomainAttachWildcardRefusal(t *testing.T) { const msg = "create CNAME _acme-challenge.tickets.example.com -> _acme-challenge.tickets.example.com.acme.oncarlos.com, then retry" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) json.NewEncoder(w).Encode(map[string]any{"error": msg}) })) defer ts.Close() s := newTestSession(ts.URL) yes := true _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com", "", "", "", &yes, nil) if err == nil || err.Error() != msg { t.Fatalf("domainAttach against a 422 = %v, want exactly %q", err, msg) } } // TestAPISessionDomainAttachWildcardCatchallWireShape: wildcard/catchall // pointers marshal only when non-nil — nil never reaches the wire at all // (omitempty on a nil *bool), so a caller that names neither sends a body // indistinguishable from the pre-Task-5 shape. func TestAPISessionDomainAttachWildcardCatchallWireShape(t *testing.T) { var gotBody map[string]json.RawMessage ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewDecoder(r.Body).Decode(&gotBody) json.NewEncoder(w).Encode(map[string]any{"host": "tickets.example.com", "route": "go.tito.io", "created": true}) })) defer ts.Close() s := newTestSession(ts.URL) // Neither named: the wire body must carry no "wildcard"/"catchall" keys // at all, not false-valued ones — an absent key is what "leave // unchanged" means to the door. if _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com", "", "", "", nil, nil); err != nil { t.Fatalf("domainAttach: %v", err) } if _, ok := gotBody["wildcard"]; ok { t.Fatalf("body = %v, want no wildcard key when nil", gotBody) } if _, ok := gotBody["catchall"]; ok { t.Fatalf("body = %v, want no catchall key when nil", gotBody) } // wildcard named true, catchall named false: both keys present, with // their own values — false is a real request ("turn it off"), not an // absence. yes, no := true, false if _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com", "", "", "", &yes, &no); err != nil { t.Fatalf("domainAttach: %v", err) } var w, c bool if err := json.Unmarshal(gotBody["wildcard"], &w); err != nil || !w { t.Fatalf("wildcard = %v (err %v), want true", gotBody["wildcard"], err) } if err := json.Unmarshal(gotBody["catchall"], &c); err != nil || c { t.Fatalf("catchall = %v (err %v), want false", gotBody["catchall"], 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", "", "", "", nil, nil); 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 Wildcard, Catchall *bool Platform bool } attachCreated bool lastDetachPath string detachRemoved bool // paths is what the per-host GET answers as the hostname's PUBLISHED // prefix map, and lastPathsPath records the URL it was asked for. nil // means the console sends no `paths` key at all — the shape a deployment // predating path routing sends. paths map[string]string lastPathsPath string } 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.MethodGet && r.URL.Path == "/api/cli/apps": // The memberships fetch resolvedAccount makes when -account is // omitted (project-defaults spec) — before this row existed the // catch-all GET below answered it with domains JSON, which // decodes to zero memberships and a fatal. json.NewEncoder(w).Encode(map[string]any{"accounts": []map[string]any{ {"sqid": "bab", "name": "ops", "role": "owner", "apps": []string{"tito"}}, }}) 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 && strings.Contains(r.URL.Path, "/domains/"): // The per-host claim door, which carries the published prefix // map. Matched BEFORE the catch-all list GET below, which would // otherwise answer it with a list body and no `paths` key at all // — making an assertion about the map impossible to distinguish // from a client that never read it. fc.lastPathsPath = r.URL.Path body := map[string]any{"domain": map[string]any{"host": "tickets.example.com"}} if fc.paths != nil { body["paths"] = fc.paths } json.NewEncoder(w).Encode(body) 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, "wildcard": true, "catchall": true, "delegation_status": "delegated"}, {"host": "new.example.com", "route": "slug.go.tito.io", "status": "pending", "wildcard": false, "catchall": false}, // guide.example.com is not pointing yet and carries guidance — // the row TestRunDomainsAttachPrintsDNSGuidanceWhenNotPointing // and TestRunDomainsListPrintsTable's GUIDANCE-column assertion // exercise, alongside tickets.example.com's pointing case and // new.example.com's no-guidance-yet ("pending", no dns_status // sent at all) case above. {"host": "guide.example.com", "route": "default.go.tito.io", "status": "pending", "dns_status": "elsewhere", "guidance": "Point an A record for guide.example.com at 203.0.113.5. Checks run every few minutes."}, // flaky.example.com's lookup FAILED on the box (our resolver, // not the member's record) — the door sends "unresolved" with // no guidance, and TestRunDomainsListRendersUnresolvedHonestly // pins that the CLI prints it as such, never as "pending" (no // answer yet) and never with a "Create:" instruction. {"host": "flaky.example.com", "route": "default.go.tito.io", "status": "live", "dns_status": "unresolved"}, // An instance catch-all and one name derived from it. {"host": "*.teams.example.com", "route": "", "status": "live", "state": "live", "instances": true}, {"host": "acme.teams.example.com", "route": "acme.go.tito.io", "status": "live", "state": "live", "dns_status": "pointing", "via": "*.teams.example.com"}, }, }) 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 } // TestRunDomainsAttachPrintsAttached also pins the no-infra completion the // spec asks for (§5): a successful attach re-fetches the domain list and // prints the just-attached host's own DNS stamp — here "pointing", since // the fake's fixed GET row for tickets.example.com already carries it — so // the command answers "is this live?" without a second `carlos domains // list`. 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) } out := buf.String() if !strings.Contains(out, "attached tickets.example.com -> slug.go.tito.io") { t.Fatalf("stdout = %q, want the attached confirmation", out) } if !strings.Contains(out, "DNS: pointing") { t.Fatalf("stdout = %q, want the DNS stamp line completing the job", out) } } // TestRunDomainsAttachPrintsDNSGuidanceWhenNotPointing pins the other arm: // a host whose DNS is not pointing yet gets the console's own guidance text // verbatim, ending the command's output with exactly what the member still // has to do (spec §5's "completes the job or says exactly what is left"). func TestRunDomainsAttachPrintsDNSGuidanceWhenNotPointing(t *testing.T) { domainsCLIFixture(t) buf := captureStdout(t) runDomains([]string{"attach", "-app", "tito", "-route", "default.go.tito.io", "guide.example.com"}) want := "DNS: elsewhere — Point an A record for guide.example.com at 203.0.113.5. Checks run every few minutes." if out := buf.String(); !strings.Contains(out, want) { t.Fatalf("stdout = %q, want it to contain %q", out, want) } } // TestRunDomainsAttachPrintsUnresolvedHonestly: an attach onto a host whose // DNS lookup FAILED (our resolver — flaky.example.com's fixed "unresolved" // row, no guidance) must complete with that literal status, never with a // "Create:" instruction telling the member to fix a record we never even // managed to check. func TestRunDomainsAttachPrintsUnresolvedHonestly(t *testing.T) { domainsCLIFixture(t) buf := captureStdout(t) runDomains([]string{"attach", "-app", "tito", "-route", "default.go.tito.io", "flaky.example.com"}) out := buf.String() if !strings.Contains(out, "DNS: unresolved") { t.Fatalf("stdout = %q, want a plain \"DNS: unresolved\" line", out) } if strings.Contains(out, "Create:") { t.Fatalf("stdout = %q, must not print guidance for a lookup WE failed", out) } } 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 // (including the GUIDANCE column added for the no-infra rule — the DNS // stamp alone tells a member DNS is wrong, but only guidance tells them // what to do about it) and falls back to "-" for a row with no // dns_status/cert_expires_at/guidance. func TestRunDomainsListPrintsTable(t *testing.T) { domainsCLIFixture(t) buf := captureStdout(t) runDomains([]string{"list", "-app", "tito"}) out := buf.String() for _, want := range []string{ "HOST", "ROUTE", "STATE", "DNS", "CERT-EXPIRES", "WILDCARD", "CATCHALL", "DELEGATION", "GUIDANCE", "tickets.example.com", "slug.go.tito.io", "live", "pointing", "2027-01-15", "new.example.com", "pending", "guide.example.com", "elsewhere", "Point an A record for guide.example.com at 203.0.113.5. Checks run every few minutes.", } { if !strings.Contains(out, want) { t.Fatalf("stdout missing %q:\n%s", want, out) } } lines := strings.Split(out, "\n") var wildcardLine, plainLine string for _, l := range lines { if strings.Contains(l, "tickets.example.com") { wildcardLine = l } if strings.Contains(l, "new.example.com") { plainLine = l } } // tickets.example.com carries wildcard+catchall+delegated: "yes" twice // and "delegated" once, followed only by the trailing GUIDANCE cell — // "-" here, since the row is pointing and the door sends no guidance. if wildcardLine == "" { t.Fatal("no row for tickets.example.com") } if fields := strings.Fields(wildcardLine); len(fields) < 4 || fields[len(fields)-4] != "yes" || fields[len(fields)-3] != "yes" || fields[len(fields)-2] != "delegated" || fields[len(fields)-1] != "-" { t.Fatalf("wildcard row = %q, want yes/yes/delegated/- in the last four columns", wildcardLine) } // The pending row's dns/cert columns read "-", and wildcard/catchall are // blank (not "yes") with delegation and guidance both "-" — the door // omits both fields. if plainLine == "" { t.Fatal("no row for new.example.com") } if fields := strings.Fields(plainLine); len(fields) < 2 || fields[len(fields)-2] != "-" || fields[len(fields)-1] != "-" { t.Fatalf("pending row = %q, want delegation and guidance \"-\"", plainLine) } if strings.Contains(plainLine, "yes") { t.Fatalf("pending row = %q, want no wildcard/catchall yes when both are false", plainLine) } } // TestRunDomainsListRendersUnresolvedHonestly pins the CLI's half of the // consumer sweep: "unresolved" means the LOOKUP failed on the box (our // resolver), not that the member's record is missing — the DNS column must // print the literal status, never fall back to "pending" (which means "no // answer yet", a different and better state), and the GUIDANCE column must // stay "-" since the door sends none for it. func TestRunDomainsListRendersUnresolvedHonestly(t *testing.T) { domainsCLIFixture(t) buf := captureStdout(t) runDomains([]string{"list", "-app", "tito"}) var flakyLine string for _, l := range strings.Split(buf.String(), "\n") { if strings.Contains(l, "flaky.example.com") { flakyLine = l } } if flakyLine == "" { t.Fatal("no row for flaky.example.com") } fields := strings.Fields(flakyLine) if len(fields) < 4 { t.Fatalf("flaky row = %q, too few columns", flakyLine) } if dns := fields[3]; dns != "unresolved" { t.Fatalf("flaky row DNS column = %q, want the literal \"unresolved\" status (not \"pending\")", dns) } if guidance := fields[len(fields)-1]; guidance != "-" { t.Fatalf("flaky row GUIDANCE column = %q, want \"-\" — our lookup failure is not the member's problem to fix", guidance) } if strings.Contains(flakyLine, "Create:") || strings.Contains(flakyLine, "Point an A record") { t.Fatalf("flaky row = %q, must not tell the member to (re)point DNS we failed to check", flakyLine) } } // TestRunDomainsAttachWildcardFlagSentOnlyWhenNamed: -wildcard/-catchall are // tri-state on the wire, the way -target already is on `apps place` — // fs.Visit is what makes "not named" and "named false" distinguishable, and // this is the regression that would silently break if runDomainsAttach ever // switched to reading *wildcard/*catchall unconditionally. func TestRunDomainsAttachWildcardFlagSentOnlyWhenNamed(t *testing.T) { fc := domainsCLIFixture(t) // Bare attach: neither flag named, neither pointer sent. runDomains([]string{"attach", "-app", "tito", "tickets.example.com"}) if fc.lastAttachBody.Wildcard != nil || fc.lastAttachBody.Catchall != nil { t.Fatalf("bare attach body = %+v, want both wildcard and catchall unset", fc.lastAttachBody) } // -wildcard named (implicitly true): wildcard sent true, catchall still // unset — naming one flag must not touch the other. runDomains([]string{"attach", "-app", "tito", "-wildcard", "tickets.example.com"}) if fc.lastAttachBody.Wildcard == nil || !*fc.lastAttachBody.Wildcard { t.Fatalf("wildcard body = %+v, want wildcard=true", fc.lastAttachBody) } if fc.lastAttachBody.Catchall != nil { t.Fatalf("wildcard body = %+v, want catchall still unset", fc.lastAttachBody) } // -wildcard=false named explicitly: a real request to turn it off, sent // as a present false, not treated as "not named". runDomains([]string{"attach", "-app", "tito", "-wildcard=false", "tickets.example.com"}) if fc.lastAttachBody.Wildcard == nil || *fc.lastAttachBody.Wildcard { t.Fatalf("wildcard=false body = %+v, want wildcard present and false", fc.lastAttachBody) } // Both named: both sent. runDomains([]string{"attach", "-app", "tito", "-wildcard", "-catchall", "tickets.example.com"}) if fc.lastAttachBody.Wildcard == nil || !*fc.lastAttachBody.Wildcard { t.Fatalf("both body = %+v, want wildcard=true", fc.lastAttachBody) } if fc.lastAttachBody.Catchall == nil || !*fc.lastAttachBody.Catchall { t.Fatalf("both body = %+v, want catchall=true", fc.lastAttachBody) } } // TestRunDomainsPathsPrintsThePublishedMap: the read surface for path // routing. It prints the map as PUBLISHED, sorted by prefix, and says in // words that this is desired state — because the other surface (`carlos // routes`) prints the rows a box DERIVED, and the difference between the two // is the whole diagnostic: the reconcile pass drops an entry whose target is // not a same-account route on that box, so a prefix visible here and not // there is a flip the deployment refused. func TestRunDomainsPathsPrintsThePublishedMap(t *testing.T) { fc := domainsCLIFixture(t) // Deliberately out of alphabetical order in the map, and with one target // that is not a route of this deployment at all — the refused entry this // surface exists to make visible. fc.paths = map[string]string{ "paulca": "go.tito.io", "bigcorp": "not-deployed-here.example", "acme": "go.tito.io", } buf := captureStdout(t) runDomains([]string{"paths", "-app", "tito", "ti.to"}) out := buf.String() // It asked the per-host claim door for the hostname it was given. if !strings.HasSuffix(fc.lastPathsPath, "/domains/ti.to") { t.Fatalf("asked for %q, want the per-host claim door for ti.to", fc.lastPathsPath) } for _, want := range []string{ "PREFIX", "TARGET", "/acme", "/bigcorp", "/paulca", "go.tito.io", "not-deployed-here.example", } { if !strings.Contains(out, want) { t.Fatalf("stdout missing %q:\n%s", want, out) } } // Sorted by prefix: a map has no order, and a listing that reshuffles // between calls is one nobody can diff. acme, bigcorp, paulca := strings.Index(out, "/acme"), strings.Index(out, "/bigcorp"), strings.Index(out, "/paulca") if !(acme < bigcorp && bigcorp < paulca) { t.Fatalf("rows are not prefix-sorted (acme=%d bigcorp=%d paulca=%d):\n%s", acme, bigcorp, paulca, out) } // And it says which KIND of state this is. Without that line a reader // takes a published map for what is serving, which is the exact confusion // that makes a refused flip look like a working one. if !strings.Contains(out, "published desired state") || !strings.Contains(out, "carlos routes") { t.Fatalf("stdout does not distinguish published state from what a box serves:\n%s", out) } } // TestRunDomainsPathsSaysSoWhenThereAreNone: a hostname with no prefix map // prints a line, not a bare header. An older console sends no `paths` key at // all and must read the same way — "this hostname splits no traffic by path" // is the true answer for a deployment that has no path routing. func TestRunDomainsPathsSaysSoWhenThereAreNone(t *testing.T) { for _, tc := range []struct { name string paths map[string]string }{ {"an empty published map", map[string]string{}}, {"a console that predates path routing", nil}, } { t.Run(tc.name, func(t *testing.T) { fc := domainsCLIFixture(t) fc.paths = tc.paths buf := captureStdout(t) runDomains([]string{"paths", "-app", "tito", "ti.to"}) if out := buf.String(); !strings.Contains(out, "splits no traffic by path") { t.Fatalf("stdout = %q, want a line saying there are none", out) } }) } } // ---- 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 (AGENTS.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"}, // The no-app cases stop on the missing SESSION since the // project-defaults spec (2026-08-13): -app resolves once a session // exists, so with nothing configured the real blocker is named — // not a usage line for a flag that is no longer required. {"attach no app", []string{"domains", "attach", "tickets.example.com"}, "no deployment target configured"}, {"attach no positional host", []string{"domains", "attach", "-app", "tito"}, "usage: carlos domains attach"}, {"detach no app", []string{"domains", "detach", "tickets.example.com"}, "no deployment target configured"}, {"detach no positional host", []string{"domains", "detach", "-app", "tito"}, "usage: carlos domains detach"}, {"list no app", []string{"domains", "list"}, "no deployment target configured"}, {"paths no app", []string{"domains", "paths", "ti.to"}, "no deployment target configured"}, {"paths no positional host", []string{"domains", "paths", "-app", "tito"}, "usage: carlos domains paths"}, } { 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) } }) } } // TestRunDomainsAttachPlatformFlag: --platform asks for the operator's door // (#209) and is sent only when named. func TestRunDomainsAttachPlatformFlag(t *testing.T) { fc := domainsCLIFixture(t) runDomains([]string{"attach", "-app", "tito", "tickets.example.com"}) if fc.lastAttachBody.Platform { t.Fatalf("bare attach body = %+v, want platform unset", fc.lastAttachBody) } runDomains([]string{"attach", "-app", "tito", "--platform", "carloku.example.com"}) if !fc.lastAttachBody.Platform || fc.lastAttachBody.Host != "carloku.example.com" { t.Fatalf("--platform body = %+v, want platform true", fc.lastAttachBody) } } // TestRunDomainsInstanceCatchall: the star is the whole instruction — the // body carries the host and nothing else — and the list marks the // catch-all and the names derived from it. func TestRunDomainsInstanceCatchall(t *testing.T) { fc := domainsCLIFixture(t) fc.attachCreated = true buf := captureStdout(t) runDomains([]string{"attach", "-app", "tito", "*.teams.example.com"}) if b := fc.lastAttachBody; b.Host != "*.teams.example.com" || b.Route != "" || b.Wildcard != nil || b.Catchall != nil || b.Platform { t.Fatalf("console saw body %+v", b) } if out := buf.String(); !strings.Contains(out, "attached *.teams.example.com ->