| 1 | // Tests for the `carlos domains` verb family — the CLI door onto |
| 2 | // internal/console/apidomains.go (Task 10, issue #151). Same two-layer shape |
| 3 | // fleets_cli_test.go established: apiSession-level tests drive the three new |
| 4 | // client methods (apimode.go) directly against an httptest fake console |
| 5 | // (path, body, wire-shape round trips), and RunDomains-level tests drive the |
| 6 | // full verb (flag parsing, printing) against the same kind of fake console, |
| 7 | // credentials written straight to disk. Usage-error paths (log.Fatal, would |
| 8 | // kill an in-process test binary) run as a real subprocess, carlosTestEnv + |
| 9 | // scratch cmd.Dir per AGENTS.md's rule. |
| 10 | package main |
| 11 | |
| 12 | import ( |
| 13 | "context" |
| 14 | "encoding/json" |
| 15 | "net/http" |
| 16 | "net/http/httptest" |
| 17 | "os/exec" |
| 18 | "path/filepath" |
| 19 | "strings" |
| 20 | "testing" |
| 21 | ) |
| 22 | |
| 23 | // ---- apiSession-level: path + body + wire-shape round trips --------------- |
| 24 | |
| 25 | // TestAPISessionDomainAttach: domainAttach POSTs {host,route} to the app's |
| 26 | // domains door and decodes created + the route the server resolved. |
| 27 | func TestAPISessionDomainAttach(t *testing.T) { |
| 28 | var gotPath, gotMethod string |
| 29 | var gotBody struct{ Host, Route string } |
| 30 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 31 | gotPath, gotMethod = r.URL.Path, r.Method |
| 32 | json.NewDecoder(r.Body).Decode(&gotBody) |
| 33 | json.NewEncoder(w).Encode(map[string]any{ |
| 34 | "host": gotBody.Host, "route": gotBody.Route, "created": true, |
| 35 | }) |
| 36 | })) |
| 37 | defer ts.Close() |
| 38 | s := newTestSession(ts.URL) |
| 39 | |
| 40 | created, route, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", |
| 41 | "tickets.example.com", "slug.go.tito.io", "", "", nil, nil) |
| 42 | if err != nil { |
| 43 | t.Fatalf("domainAttach: %v", err) |
| 44 | } |
| 45 | if gotPath != "/api/cli/apps/bab/tito/domains" || gotMethod != http.MethodPost { |
| 46 | t.Fatalf("request = %s %s, want POST .../tito/domains", gotMethod, gotPath) |
| 47 | } |
| 48 | if gotBody.Host != "tickets.example.com" || gotBody.Route != "slug.go.tito.io" { |
| 49 | t.Fatalf("body = %+v", gotBody) |
| 50 | } |
| 51 | if !created || route != "slug.go.tito.io" { |
| 52 | t.Fatalf("domainAttach = created=%v route=%q", created, route) |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | // TestAPISessionDomainAttachNoOp: created=false round-trips (the re-attach, |
| 57 | // nothing-changed case), and the server's resolved route still comes back |
| 58 | // even when the caller sent none. |
| 59 | func TestAPISessionDomainAttachNoOp(t *testing.T) { |
| 60 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 61 | json.NewEncoder(w).Encode(map[string]any{ |
| 62 | "host": "tickets.example.com", "route": "default.go.tito.io", "created": false, |
| 63 | }) |
| 64 | })) |
| 65 | defer ts.Close() |
| 66 | s := newTestSession(ts.URL) |
| 67 | |
| 68 | created, route, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", |
| 69 | "tickets.example.com", "", "", "", nil, nil) |
| 70 | if err != nil { |
| 71 | t.Fatalf("domainAttach: %v", err) |
| 72 | } |
| 73 | if created || route != "default.go.tito.io" { |
| 74 | t.Fatalf("domainAttach = created=%v route=%q, want created=false route=default.go.tito.io", created, route) |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | // TestAPISessionDomainAttachConflict: a 409 surfaces the server's |
| 79 | // "hostname unavailable" message verbatim, apiErrorFromBody's json-first |
| 80 | // decode doing the work — no special-casing needed on this side. |
| 81 | func TestAPISessionDomainAttachConflict(t *testing.T) { |
| 82 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 83 | w.WriteHeader(http.StatusConflict) |
| 84 | json.NewEncoder(w).Encode(map[string]any{"error": "hostname unavailable"}) |
| 85 | })) |
| 86 | defer ts.Close() |
| 87 | s := newTestSession(ts.URL) |
| 88 | |
| 89 | _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "taken.example.com", "", "", "", nil, nil) |
| 90 | if err == nil || err.Error() != "hostname unavailable" { |
| 91 | t.Fatalf("domainAttach against 409 = %v, want exactly \"hostname unavailable\"", err) |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | // TestAPISessionDomainAttachWildcardRefusal: a 422 (the wildcard/catchall |
| 96 | // gate) surfaces its own message verbatim too — the CNAME record the caller |
| 97 | // needs to create, exactly as the door wrote it, not summarized. |
| 98 | func TestAPISessionDomainAttachWildcardRefusal(t *testing.T) { |
| 99 | const msg = "create CNAME _acme-challenge.tickets.example.com -> _acme-challenge.tickets.example.com.acme.oncarlos.com, then retry" |
| 100 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 101 | w.WriteHeader(http.StatusUnprocessableEntity) |
| 102 | json.NewEncoder(w).Encode(map[string]any{"error": msg}) |
| 103 | })) |
| 104 | defer ts.Close() |
| 105 | s := newTestSession(ts.URL) |
| 106 | |
| 107 | yes := true |
| 108 | _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com", "", "", "", &yes, nil) |
| 109 | if err == nil || err.Error() != msg { |
| 110 | t.Fatalf("domainAttach against a 422 = %v, want exactly %q", err, msg) |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | // TestAPISessionDomainAttachWildcardCatchallWireShape: wildcard/catchall |
| 115 | // pointers marshal only when non-nil — nil never reaches the wire at all |
| 116 | // (omitempty on a nil *bool), so a caller that names neither sends a body |
| 117 | // indistinguishable from the pre-Task-5 shape. |
| 118 | func TestAPISessionDomainAttachWildcardCatchallWireShape(t *testing.T) { |
| 119 | var gotBody map[string]json.RawMessage |
| 120 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 121 | json.NewDecoder(r.Body).Decode(&gotBody) |
| 122 | json.NewEncoder(w).Encode(map[string]any{"host": "tickets.example.com", "route": "go.tito.io", "created": true}) |
| 123 | })) |
| 124 | defer ts.Close() |
| 125 | s := newTestSession(ts.URL) |
| 126 | |
| 127 | // Neither named: the wire body must carry no "wildcard"/"catchall" keys |
| 128 | // at all, not false-valued ones — an absent key is what "leave |
| 129 | // unchanged" means to the door. |
| 130 | if _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com", "", "", "", nil, nil); err != nil { |
| 131 | t.Fatalf("domainAttach: %v", err) |
| 132 | } |
| 133 | if _, ok := gotBody["wildcard"]; ok { |
| 134 | t.Fatalf("body = %v, want no wildcard key when nil", gotBody) |
| 135 | } |
| 136 | if _, ok := gotBody["catchall"]; ok { |
| 137 | t.Fatalf("body = %v, want no catchall key when nil", gotBody) |
| 138 | } |
| 139 | |
| 140 | // wildcard named true, catchall named false: both keys present, with |
| 141 | // their own values — false is a real request ("turn it off"), not an |
| 142 | // absence. |
| 143 | yes, no := true, false |
| 144 | if _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com", "", "", "", &yes, &no); err != nil { |
| 145 | t.Fatalf("domainAttach: %v", err) |
| 146 | } |
| 147 | var w, c bool |
| 148 | if err := json.Unmarshal(gotBody["wildcard"], &w); err != nil || !w { |
| 149 | t.Fatalf("wildcard = %v (err %v), want true", gotBody["wildcard"], err) |
| 150 | } |
| 151 | if err := json.Unmarshal(gotBody["catchall"], &c); err != nil || c { |
| 152 | t.Fatalf("catchall = %v (err %v), want false", gotBody["catchall"], err) |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | // TestAPISessionDomainDetach: domainDetach DELETEs with the host |
| 157 | // path-escaped and decodes removed. |
| 158 | func TestAPISessionDomainDetach(t *testing.T) { |
| 159 | var gotPath, gotMethod string |
| 160 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 161 | gotPath, gotMethod = r.URL.EscapedPath(), r.Method |
| 162 | json.NewEncoder(w).Encode(map[string]any{"host": "tickets.example.com", "removed": true}) |
| 163 | })) |
| 164 | defer ts.Close() |
| 165 | s := newTestSession(ts.URL) |
| 166 | |
| 167 | removed, err := s.domainDetach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com") |
| 168 | if err != nil { |
| 169 | t.Fatalf("domainDetach: %v", err) |
| 170 | } |
| 171 | if gotMethod != http.MethodDelete { |
| 172 | t.Fatalf("method = %s, want DELETE", gotMethod) |
| 173 | } |
| 174 | if gotPath != "/api/cli/apps/bab/tito/domains/tickets.example.com" { |
| 175 | t.Fatalf("path = %s, want the app's domains door with the host appended", gotPath) |
| 176 | } |
| 177 | if !removed { |
| 178 | t.Fatal("removed = false, want true") |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | // TestAPISessionDomainDetachWasNotAttached: removed=false round-trips |
| 183 | // unchanged — the door's own three-ways-the-same neutrality (spec §5). |
| 184 | func TestAPISessionDomainDetachWasNotAttached(t *testing.T) { |
| 185 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 186 | json.NewEncoder(w).Encode(map[string]any{"host": "ghost.example.com", "removed": false}) |
| 187 | })) |
| 188 | defer ts.Close() |
| 189 | s := newTestSession(ts.URL) |
| 190 | |
| 191 | removed, err := s.domainDetach(context.Background(), apiAcct{Seg: "bab"}, "tito", "ghost.example.com") |
| 192 | if err != nil { |
| 193 | t.Fatalf("domainDetach: %v", err) |
| 194 | } |
| 195 | if removed { |
| 196 | t.Fatal("removed = true, want false") |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | // TestAPISessionDomainList: domainList GETs the domains door and decodes the |
| 201 | // "domains" wrapper, tolerating rows that omit dns_status/cert_expires_at |
| 202 | // (a claim no live box has materialized yet — apidomains.go's "pending"). |
| 203 | func TestAPISessionDomainList(t *testing.T) { |
| 204 | var gotPath, gotMethod string |
| 205 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 206 | gotPath, gotMethod = r.URL.Path, r.Method |
| 207 | json.NewEncoder(w).Encode(map[string]any{ |
| 208 | "domains": []map[string]any{ |
| 209 | {"host": "tickets.example.com", "route": "slug.go.tito.io", "status": "live", |
| 210 | "dns_status": "pointing", "cert_expires_at": 1799999999}, |
| 211 | {"host": "new.example.com", "route": "slug.go.tito.io", "status": "pending"}, |
| 212 | }, |
| 213 | }) |
| 214 | })) |
| 215 | defer ts.Close() |
| 216 | s := newTestSession(ts.URL) |
| 217 | |
| 218 | recs, err := s.domainList(context.Background(), apiAcct{Seg: "bab"}, "tito") |
| 219 | if err != nil { |
| 220 | t.Fatalf("domainList: %v", err) |
| 221 | } |
| 222 | if gotPath != "/api/cli/apps/bab/tito/domains" || gotMethod != http.MethodGet { |
| 223 | t.Fatalf("request = %s %s, want GET .../tito/domains", gotMethod, gotPath) |
| 224 | } |
| 225 | if len(recs) != 2 { |
| 226 | t.Fatalf("domainList returned %d records, want 2", len(recs)) |
| 227 | } |
| 228 | if recs[0].Host != "tickets.example.com" || recs[0].Route != "slug.go.tito.io" || |
| 229 | recs[0].Status != "live" || recs[0].DNSStatus != "pointing" || recs[0].CertExpiresAt != 1799999999 { |
| 230 | t.Fatalf("recs[0] = %+v", recs[0]) |
| 231 | } |
| 232 | if recs[1].Host != "new.example.com" || recs[1].Status != "pending" || |
| 233 | recs[1].DNSStatus != "" || recs[1].CertExpiresAt != 0 { |
| 234 | t.Fatalf("recs[1] = %+v, want empty dns_status and zero cert_expires_at", recs[1]) |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | // TestAPISessionDomainDoorsSurfaceServerErrors: a non-2xx from any of the |
| 239 | // three domain doors surfaces an error rather than a swallowed one. |
| 240 | func TestAPISessionDomainDoorsSurfaceServerErrors(t *testing.T) { |
| 241 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 242 | w.WriteHeader(http.StatusNotFound) |
| 243 | w.Write([]byte("404 page not found\n")) |
| 244 | })) |
| 245 | defer ts.Close() |
| 246 | s := newTestSession(ts.URL) |
| 247 | acct := apiAcct{Seg: "bab"} |
| 248 | |
| 249 | if _, _, err := s.domainAttach(context.Background(), acct, "ghost", "x.example.com", "", "", "", nil, nil); err == nil { |
| 250 | t.Fatal("domainAttach against a 404 must error") |
| 251 | } |
| 252 | if _, err := s.domainDetach(context.Background(), acct, "ghost", "x.example.com"); err == nil { |
| 253 | t.Fatal("domainDetach against a 404 must error") |
| 254 | } |
| 255 | if _, err := s.domainList(context.Background(), acct, "ghost"); err == nil { |
| 256 | t.Fatal("domainList against a 404 must error") |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | // ---- RunDomains-level: full verb, flag parsing + printing ----------------- |
| 261 | |
| 262 | // fakeDomainsConsole is a minimal in-process console answering every |
| 263 | // domains door with fixed, easily-asserted bodies. |
| 264 | type fakeDomainsConsole struct { |
| 265 | ts *httptest.Server |
| 266 | lastAttachPath string |
| 267 | lastAttachBody struct { |
| 268 | Host, Route string |
| 269 | Wildcard, Catchall *bool |
| 270 | Platform bool |
| 271 | } |
| 272 | attachCreated bool |
| 273 | lastDetachPath string |
| 274 | detachRemoved bool |
| 275 | // paths is what the per-host GET answers as the hostname's PUBLISHED |
| 276 | // prefix map, and lastPathsPath records the URL it was asked for. nil |
| 277 | // means the console sends no `paths` key at all — the shape a deployment |
| 278 | // predating path routing sends. |
| 279 | paths map[string]string |
| 280 | lastPathsPath string |
| 281 | } |
| 282 | |
| 283 | func newFakeDomainsConsole(t *testing.T) *fakeDomainsConsole { |
| 284 | t.Helper() |
| 285 | fc := &fakeDomainsConsole{attachCreated: true, detachRemoved: true} |
| 286 | fc.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 287 | switch { |
| 288 | case r.Method == http.MethodGet && r.URL.Path == "/api/cli/apps": |
| 289 | // The memberships fetch resolvedAccount makes when -account is |
| 290 | // omitted (project-defaults spec) — before this row existed the |
| 291 | // catch-all GET below answered it with domains JSON, which |
| 292 | // decodes to zero memberships and a fatal. |
| 293 | json.NewEncoder(w).Encode(map[string]any{"accounts": []map[string]any{ |
| 294 | {"sqid": "bab", "name": "ops", "role": "owner", "apps": []string{"tito"}}, |
| 295 | }}) |
| 296 | case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/domains"): |
| 297 | fc.lastAttachPath = r.URL.Path |
| 298 | json.NewDecoder(r.Body).Decode(&fc.lastAttachBody) |
| 299 | route := fc.lastAttachBody.Route |
| 300 | if route == "" { |
| 301 | route = "default.go.tito.io" |
| 302 | } |
| 303 | json.NewEncoder(w).Encode(map[string]any{ |
| 304 | "host": fc.lastAttachBody.Host, "route": route, "created": fc.attachCreated, |
| 305 | }) |
| 306 | case r.Method == http.MethodDelete: |
| 307 | fc.lastDetachPath = r.URL.Path |
| 308 | json.NewEncoder(w).Encode(map[string]any{"host": "x", "removed": fc.detachRemoved}) |
| 309 | case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/domains/"): |
| 310 | // The per-host claim door, which carries the published prefix |
| 311 | // map. Matched BEFORE the catch-all list GET below, which would |
| 312 | // otherwise answer it with a list body and no `paths` key at all |
| 313 | // — making an assertion about the map impossible to distinguish |
| 314 | // from a client that never read it. |
| 315 | fc.lastPathsPath = r.URL.Path |
| 316 | body := map[string]any{"domain": map[string]any{"host": "tickets.example.com"}} |
| 317 | if fc.paths != nil { |
| 318 | body["paths"] = fc.paths |
| 319 | } |
| 320 | json.NewEncoder(w).Encode(body) |
| 321 | case r.Method == http.MethodGet: |
| 322 | json.NewEncoder(w).Encode(map[string]any{ |
| 323 | "domains": []map[string]any{ |
| 324 | {"host": "tickets.example.com", "route": "slug.go.tito.io", "status": "live", |
| 325 | "dns_status": "pointing", "cert_expires_at": 1799999999, |
| 326 | "wildcard": true, "catchall": true, "delegation_status": "delegated"}, |
| 327 | {"host": "new.example.com", "route": "slug.go.tito.io", "status": "pending", |
| 328 | "wildcard": false, "catchall": false}, |
| 329 | // guide.example.com is not pointing yet and carries guidance — |
| 330 | // the row TestRunDomainsAttachPrintsDNSGuidanceWhenNotPointing |
| 331 | // and TestRunDomainsListPrintsTable's GUIDANCE-column assertion |
| 332 | // exercise, alongside tickets.example.com's pointing case and |
| 333 | // new.example.com's no-guidance-yet ("pending", no dns_status |
| 334 | // sent at all) case above. |
| 335 | {"host": "guide.example.com", "route": "default.go.tito.io", "status": "pending", |
| 336 | "dns_status": "elsewhere", |
| 337 | "guidance": "Point an A record for guide.example.com at 203.0.113.5. Checks run every few minutes."}, |
| 338 | // flaky.example.com's lookup FAILED on the box (our resolver, |
| 339 | // not the member's record) — the door sends "unresolved" with |
| 340 | // no guidance, and TestRunDomainsListRendersUnresolvedHonestly |
| 341 | // pins that the CLI prints it as such, never as "pending" (no |
| 342 | // answer yet) and never with a "Create:" instruction. |
| 343 | {"host": "flaky.example.com", "route": "default.go.tito.io", "status": "live", |
| 344 | "dns_status": "unresolved"}, |
| 345 | // An instance catch-all and one name derived from it. |
| 346 | {"host": "*.teams.example.com", "route": "", "status": "live", "state": "live", |
| 347 | "instances": true}, |
| 348 | {"host": "acme.teams.example.com", "route": "acme.go.tito.io", "status": "live", "state": "live", |
| 349 | "dns_status": "pointing", "via": "*.teams.example.com"}, |
| 350 | }, |
| 351 | }) |
| 352 | default: |
| 353 | w.WriteHeader(http.StatusNotFound) |
| 354 | } |
| 355 | })) |
| 356 | t.Cleanup(fc.ts.Close) |
| 357 | return fc |
| 358 | } |
| 359 | |
| 360 | // domainsCLIFixture wires up a fake console plus a credential pointed at it, |
| 361 | // entirely in-process — the same shape fleetsCLIFixture uses. |
| 362 | func domainsCLIFixture(t *testing.T) *fakeDomainsConsole { |
| 363 | t.Helper() |
| 364 | home := t.TempDir() |
| 365 | t.Setenv("HOME", home) |
| 366 | t.Setenv("CARLOS_CONSOLE", "") |
| 367 | t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| 368 | t.Setenv("CARLOS_DEPLOYMENT_DIR", "") |
| 369 | t.Chdir(t.TempDir()) |
| 370 | creds := filepath.Join(home, "carlos-credentials") |
| 371 | t.Setenv("CARLOS_CREDENTIALS", creds) |
| 372 | fc := newFakeDomainsConsole(t) |
| 373 | writeTestCredential(t, creds, fc.ts.URL) |
| 374 | return fc |
| 375 | } |
| 376 | |
| 377 | // TestRunDomainsAttachPrintsAttached also pins the no-infra completion the |
| 378 | // spec asks for (§5): a successful attach re-fetches the domain list and |
| 379 | // prints the just-attached host's own DNS stamp — here "pointing", since |
| 380 | // the fake's fixed GET row for tickets.example.com already carries it — so |
| 381 | // the command answers "is this live?" without a second `carlos domains |
| 382 | // list`. |
| 383 | func TestRunDomainsAttachPrintsAttached(t *testing.T) { |
| 384 | fc := domainsCLIFixture(t) |
| 385 | fc.attachCreated = true |
| 386 | buf := captureStdout(t) |
| 387 | |
| 388 | runDomains([]string{"attach", "-app", "tito", "-route", "slug.go.tito.io", "tickets.example.com"}) |
| 389 | |
| 390 | if fc.lastAttachBody.Host != "tickets.example.com" || fc.lastAttachBody.Route != "slug.go.tito.io" { |
| 391 | t.Fatalf("console saw body %+v", fc.lastAttachBody) |
| 392 | } |
| 393 | out := buf.String() |
| 394 | if !strings.Contains(out, "attached tickets.example.com -> slug.go.tito.io") { |
| 395 | t.Fatalf("stdout = %q, want the attached confirmation", out) |
| 396 | } |
| 397 | if !strings.Contains(out, "DNS: pointing") { |
| 398 | t.Fatalf("stdout = %q, want the DNS stamp line completing the job", out) |
| 399 | } |
| 400 | } |
| 401 | |
| 402 | // TestRunDomainsAttachPrintsDNSGuidanceWhenNotPointing pins the other arm: |
| 403 | // a host whose DNS is not pointing yet gets the console's own guidance text |
| 404 | // verbatim, ending the command's output with exactly what the member still |
| 405 | // has to do (spec §5's "completes the job or says exactly what is left"). |
| 406 | func TestRunDomainsAttachPrintsDNSGuidanceWhenNotPointing(t *testing.T) { |
| 407 | domainsCLIFixture(t) |
| 408 | buf := captureStdout(t) |
| 409 | |
| 410 | runDomains([]string{"attach", "-app", "tito", "-route", "default.go.tito.io", "guide.example.com"}) |
| 411 | |
| 412 | want := "DNS: elsewhere — Point an A record for guide.example.com at 203.0.113.5. Checks run every few minutes." |
| 413 | if out := buf.String(); !strings.Contains(out, want) { |
| 414 | t.Fatalf("stdout = %q, want it to contain %q", out, want) |
| 415 | } |
| 416 | } |
| 417 | |
| 418 | // TestRunDomainsAttachPrintsUnresolvedHonestly: an attach onto a host whose |
| 419 | // DNS lookup FAILED (our resolver — flaky.example.com's fixed "unresolved" |
| 420 | // row, no guidance) must complete with that literal status, never with a |
| 421 | // "Create:" instruction telling the member to fix a record we never even |
| 422 | // managed to check. |
| 423 | func TestRunDomainsAttachPrintsUnresolvedHonestly(t *testing.T) { |
| 424 | domainsCLIFixture(t) |
| 425 | buf := captureStdout(t) |
| 426 | |
| 427 | runDomains([]string{"attach", "-app", "tito", "-route", "default.go.tito.io", "flaky.example.com"}) |
| 428 | |
| 429 | out := buf.String() |
| 430 | if !strings.Contains(out, "DNS: unresolved") { |
| 431 | t.Fatalf("stdout = %q, want a plain \"DNS: unresolved\" line", out) |
| 432 | } |
| 433 | if strings.Contains(out, "Create:") { |
| 434 | t.Fatalf("stdout = %q, must not print guidance for a lookup WE failed", out) |
| 435 | } |
| 436 | } |
| 437 | |
| 438 | func TestRunDomainsAttachPrintsAlreadyAttachedOnNoOp(t *testing.T) { |
| 439 | fc := domainsCLIFixture(t) |
| 440 | fc.attachCreated = false |
| 441 | buf := captureStdout(t) |
| 442 | |
| 443 | runDomains([]string{"attach", "-app", "tito", "tickets.example.com"}) |
| 444 | |
| 445 | if !strings.Contains(buf.String(), "already attached") { |
| 446 | t.Fatalf("stdout = %q, want the already-attached confirmation", buf.String()) |
| 447 | } |
| 448 | } |
| 449 | |
| 450 | func TestRunDomainsDetachPrintsDetached(t *testing.T) { |
| 451 | fc := domainsCLIFixture(t) |
| 452 | fc.detachRemoved = true |
| 453 | buf := captureStdout(t) |
| 454 | |
| 455 | runDomains([]string{"detach", "-app", "tito", "tickets.example.com"}) |
| 456 | |
| 457 | if !strings.HasSuffix(fc.lastDetachPath, "/domains/tickets.example.com") { |
| 458 | t.Fatalf("detach path = %s, want it to end in the escaped host", fc.lastDetachPath) |
| 459 | } |
| 460 | if !strings.Contains(buf.String(), "detached tickets.example.com") { |
| 461 | t.Fatalf("stdout = %q, want the detached confirmation", buf.String()) |
| 462 | } |
| 463 | } |
| 464 | |
| 465 | func TestRunDomainsDetachPrintsWasNotAttached(t *testing.T) { |
| 466 | fc := domainsCLIFixture(t) |
| 467 | fc.detachRemoved = false |
| 468 | buf := captureStdout(t) |
| 469 | |
| 470 | runDomains([]string{"detach", "-app", "tito", "ghost.example.com"}) |
| 471 | |
| 472 | if !strings.Contains(buf.String(), "was not attached") { |
| 473 | t.Fatalf("stdout = %q, want the was-not-attached confirmation", buf.String()) |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | // TestRunDomainsListPrintsTable: the list table carries every column |
| 478 | // (including the GUIDANCE column added for the no-infra rule — the DNS |
| 479 | // stamp alone tells a member DNS is wrong, but only guidance tells them |
| 480 | // what to do about it) and falls back to "-" for a row with no |
| 481 | // dns_status/cert_expires_at/guidance. |
| 482 | func TestRunDomainsListPrintsTable(t *testing.T) { |
| 483 | domainsCLIFixture(t) |
| 484 | buf := captureStdout(t) |
| 485 | |
| 486 | runDomains([]string{"list", "-app", "tito"}) |
| 487 | |
| 488 | out := buf.String() |
| 489 | for _, want := range []string{ |
| 490 | "HOST", "ROUTE", "STATE", "DNS", "CERT-EXPIRES", "WILDCARD", "CATCHALL", "DELEGATION", "GUIDANCE", |
| 491 | "tickets.example.com", "slug.go.tito.io", "live", "pointing", "2027-01-15", |
| 492 | "new.example.com", "pending", |
| 493 | "guide.example.com", "elsewhere", |
| 494 | "Point an A record for guide.example.com at 203.0.113.5. Checks run every few minutes.", |
| 495 | } { |
| 496 | if !strings.Contains(out, want) { |
| 497 | t.Fatalf("stdout missing %q:\n%s", want, out) |
| 498 | } |
| 499 | } |
| 500 | lines := strings.Split(out, "\n") |
| 501 | var wildcardLine, plainLine string |
| 502 | for _, l := range lines { |
| 503 | if strings.Contains(l, "tickets.example.com") { |
| 504 | wildcardLine = l |
| 505 | } |
| 506 | if strings.Contains(l, "new.example.com") { |
| 507 | plainLine = l |
| 508 | } |
| 509 | } |
| 510 | // tickets.example.com carries wildcard+catchall+delegated: "yes" twice |
| 511 | // and "delegated" once, followed only by the trailing GUIDANCE cell — |
| 512 | // "-" here, since the row is pointing and the door sends no guidance. |
| 513 | if wildcardLine == "" { |
| 514 | t.Fatal("no row for tickets.example.com") |
| 515 | } |
| 516 | if fields := strings.Fields(wildcardLine); len(fields) < 4 || |
| 517 | fields[len(fields)-4] != "yes" || fields[len(fields)-3] != "yes" || |
| 518 | fields[len(fields)-2] != "delegated" || fields[len(fields)-1] != "-" { |
| 519 | t.Fatalf("wildcard row = %q, want yes/yes/delegated/- in the last four columns", wildcardLine) |
| 520 | } |
| 521 | // The pending row's dns/cert columns read "-", and wildcard/catchall are |
| 522 | // blank (not "yes") with delegation and guidance both "-" — the door |
| 523 | // omits both fields. |
| 524 | if plainLine == "" { |
| 525 | t.Fatal("no row for new.example.com") |
| 526 | } |
| 527 | if fields := strings.Fields(plainLine); len(fields) < 2 || |
| 528 | fields[len(fields)-2] != "-" || fields[len(fields)-1] != "-" { |
| 529 | t.Fatalf("pending row = %q, want delegation and guidance \"-\"", plainLine) |
| 530 | } |
| 531 | if strings.Contains(plainLine, "yes") { |
| 532 | t.Fatalf("pending row = %q, want no wildcard/catchall yes when both are false", plainLine) |
| 533 | } |
| 534 | } |
| 535 | |
| 536 | // TestRunDomainsListRendersUnresolvedHonestly pins the CLI's half of the |
| 537 | // consumer sweep: "unresolved" means the LOOKUP failed on the box (our |
| 538 | // resolver), not that the member's record is missing — the DNS column must |
| 539 | // print the literal status, never fall back to "pending" (which means "no |
| 540 | // answer yet", a different and better state), and the GUIDANCE column must |
| 541 | // stay "-" since the door sends none for it. |
| 542 | func TestRunDomainsListRendersUnresolvedHonestly(t *testing.T) { |
| 543 | domainsCLIFixture(t) |
| 544 | buf := captureStdout(t) |
| 545 | |
| 546 | runDomains([]string{"list", "-app", "tito"}) |
| 547 | |
| 548 | var flakyLine string |
| 549 | for _, l := range strings.Split(buf.String(), "\n") { |
| 550 | if strings.Contains(l, "flaky.example.com") { |
| 551 | flakyLine = l |
| 552 | } |
| 553 | } |
| 554 | if flakyLine == "" { |
| 555 | t.Fatal("no row for flaky.example.com") |
| 556 | } |
| 557 | fields := strings.Fields(flakyLine) |
| 558 | if len(fields) < 4 { |
| 559 | t.Fatalf("flaky row = %q, too few columns", flakyLine) |
| 560 | } |
| 561 | if dns := fields[3]; dns != "unresolved" { |
| 562 | t.Fatalf("flaky row DNS column = %q, want the literal \"unresolved\" status (not \"pending\")", dns) |
| 563 | } |
| 564 | if guidance := fields[len(fields)-1]; guidance != "-" { |
| 565 | t.Fatalf("flaky row GUIDANCE column = %q, want \"-\" — our lookup failure is not the member's problem to fix", guidance) |
| 566 | } |
| 567 | if strings.Contains(flakyLine, "Create:") || strings.Contains(flakyLine, "Point an A record") { |
| 568 | t.Fatalf("flaky row = %q, must not tell the member to (re)point DNS we failed to check", flakyLine) |
| 569 | } |
| 570 | } |
| 571 | |
| 572 | // TestRunDomainsAttachWildcardFlagSentOnlyWhenNamed: -wildcard/-catchall are |
| 573 | // tri-state on the wire, the way -target already is on `apps place` — |
| 574 | // fs.Visit is what makes "not named" and "named false" distinguishable, and |
| 575 | // this is the regression that would silently break if runDomainsAttach ever |
| 576 | // switched to reading *wildcard/*catchall unconditionally. |
| 577 | func TestRunDomainsAttachWildcardFlagSentOnlyWhenNamed(t *testing.T) { |
| 578 | fc := domainsCLIFixture(t) |
| 579 | |
| 580 | // Bare attach: neither flag named, neither pointer sent. |
| 581 | runDomains([]string{"attach", "-app", "tito", "tickets.example.com"}) |
| 582 | if fc.lastAttachBody.Wildcard != nil || fc.lastAttachBody.Catchall != nil { |
| 583 | t.Fatalf("bare attach body = %+v, want both wildcard and catchall unset", fc.lastAttachBody) |
| 584 | } |
| 585 | |
| 586 | // -wildcard named (implicitly true): wildcard sent true, catchall still |
| 587 | // unset — naming one flag must not touch the other. |
| 588 | runDomains([]string{"attach", "-app", "tito", "-wildcard", "tickets.example.com"}) |
| 589 | if fc.lastAttachBody.Wildcard == nil || !*fc.lastAttachBody.Wildcard { |
| 590 | t.Fatalf("wildcard body = %+v, want wildcard=true", fc.lastAttachBody) |
| 591 | } |
| 592 | if fc.lastAttachBody.Catchall != nil { |
| 593 | t.Fatalf("wildcard body = %+v, want catchall still unset", fc.lastAttachBody) |
| 594 | } |
| 595 | |
| 596 | // -wildcard=false named explicitly: a real request to turn it off, sent |
| 597 | // as a present false, not treated as "not named". |
| 598 | runDomains([]string{"attach", "-app", "tito", "-wildcard=false", "tickets.example.com"}) |
| 599 | if fc.lastAttachBody.Wildcard == nil || *fc.lastAttachBody.Wildcard { |
| 600 | t.Fatalf("wildcard=false body = %+v, want wildcard present and false", fc.lastAttachBody) |
| 601 | } |
| 602 | |
| 603 | // Both named: both sent. |
| 604 | runDomains([]string{"attach", "-app", "tito", "-wildcard", "-catchall", "tickets.example.com"}) |
| 605 | if fc.lastAttachBody.Wildcard == nil || !*fc.lastAttachBody.Wildcard { |
| 606 | t.Fatalf("both body = %+v, want wildcard=true", fc.lastAttachBody) |
| 607 | } |
| 608 | if fc.lastAttachBody.Catchall == nil || !*fc.lastAttachBody.Catchall { |
| 609 | t.Fatalf("both body = %+v, want catchall=true", fc.lastAttachBody) |
| 610 | } |
| 611 | } |
| 612 | |
| 613 | // TestRunDomainsPathsPrintsThePublishedMap: the read surface for path |
| 614 | // routing. It prints the map as PUBLISHED, sorted by prefix, and says in |
| 615 | // words that this is desired state — because the other surface (`carlos |
| 616 | // routes`) prints the rows a box DERIVED, and the difference between the two |
| 617 | // is the whole diagnostic: the reconcile pass drops an entry whose target is |
| 618 | // not a same-account route on that box, so a prefix visible here and not |
| 619 | // there is a flip the deployment refused. |
| 620 | func TestRunDomainsPathsPrintsThePublishedMap(t *testing.T) { |
| 621 | fc := domainsCLIFixture(t) |
| 622 | // Deliberately out of alphabetical order in the map, and with one target |
| 623 | // that is not a route of this deployment at all — the refused entry this |
| 624 | // surface exists to make visible. |
| 625 | fc.paths = map[string]string{ |
| 626 | "paulca": "go.tito.io", |
| 627 | "bigcorp": "not-deployed-here.example", |
| 628 | "acme": "go.tito.io", |
| 629 | } |
| 630 | buf := captureStdout(t) |
| 631 | |
| 632 | runDomains([]string{"paths", "-app", "tito", "ti.to"}) |
| 633 | |
| 634 | out := buf.String() |
| 635 | // It asked the per-host claim door for the hostname it was given. |
| 636 | if !strings.HasSuffix(fc.lastPathsPath, "/domains/ti.to") { |
| 637 | t.Fatalf("asked for %q, want the per-host claim door for ti.to", fc.lastPathsPath) |
| 638 | } |
| 639 | for _, want := range []string{ |
| 640 | "PREFIX", "TARGET", |
| 641 | "/acme", "/bigcorp", "/paulca", |
| 642 | "go.tito.io", "not-deployed-here.example", |
| 643 | } { |
| 644 | if !strings.Contains(out, want) { |
| 645 | t.Fatalf("stdout missing %q:\n%s", want, out) |
| 646 | } |
| 647 | } |
| 648 | // Sorted by prefix: a map has no order, and a listing that reshuffles |
| 649 | // between calls is one nobody can diff. |
| 650 | acme, bigcorp, paulca := strings.Index(out, "/acme"), strings.Index(out, "/bigcorp"), strings.Index(out, "/paulca") |
| 651 | if !(acme < bigcorp && bigcorp < paulca) { |
| 652 | t.Fatalf("rows are not prefix-sorted (acme=%d bigcorp=%d paulca=%d):\n%s", acme, bigcorp, paulca, out) |
| 653 | } |
| 654 | // And it says which KIND of state this is. Without that line a reader |
| 655 | // takes a published map for what is serving, which is the exact confusion |
| 656 | // that makes a refused flip look like a working one. |
| 657 | if !strings.Contains(out, "published desired state") || !strings.Contains(out, "carlos routes") { |
| 658 | t.Fatalf("stdout does not distinguish published state from what a box serves:\n%s", out) |
| 659 | } |
| 660 | } |
| 661 | |
| 662 | // TestRunDomainsPathsSaysSoWhenThereAreNone: a hostname with no prefix map |
| 663 | // prints a line, not a bare header. An older console sends no `paths` key at |
| 664 | // all and must read the same way — "this hostname splits no traffic by path" |
| 665 | // is the true answer for a deployment that has no path routing. |
| 666 | func TestRunDomainsPathsSaysSoWhenThereAreNone(t *testing.T) { |
| 667 | for _, tc := range []struct { |
| 668 | name string |
| 669 | paths map[string]string |
| 670 | }{ |
| 671 | {"an empty published map", map[string]string{}}, |
| 672 | {"a console that predates path routing", nil}, |
| 673 | } { |
| 674 | t.Run(tc.name, func(t *testing.T) { |
| 675 | fc := domainsCLIFixture(t) |
| 676 | fc.paths = tc.paths |
| 677 | buf := captureStdout(t) |
| 678 | |
| 679 | runDomains([]string{"paths", "-app", "tito", "ti.to"}) |
| 680 | |
| 681 | if out := buf.String(); !strings.Contains(out, "splits no traffic by path") { |
| 682 | t.Fatalf("stdout = %q, want a line saying there are none", out) |
| 683 | } |
| 684 | }) |
| 685 | } |
| 686 | } |
| 687 | |
| 688 | // ---- subprocess: the usage-error / bad-argument paths --------------------- |
| 689 | |
| 690 | // TestDomainsCLIUsageErrors drives every domains verb with missing or |
| 691 | // malshaped arguments as a real subprocess (buildCarlos) — the only way to |
| 692 | // observe a log.Fatal/os.Exit refusal without killing the test binary. |
| 693 | // carlosTestEnv masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE and cmd.Dir is |
| 694 | // a scratch dir (AGENTS.md's rule), so a case that DID try to dial out would |
| 695 | // fail on "not logged in", not silently succeed. |
| 696 | func TestDomainsCLIUsageErrors(t *testing.T) { |
| 697 | bin := buildCarlos(t) |
| 698 | scratch := t.TempDir() |
| 699 | env := carlosTestEnv(t) |
| 700 | |
| 701 | run := func(args ...string) (string, error) { |
| 702 | cmd := exec.Command(bin, args...) |
| 703 | cmd.Env = env |
| 704 | cmd.Dir = scratch |
| 705 | out, err := cmd.CombinedOutput() |
| 706 | return string(out), err |
| 707 | } |
| 708 | |
| 709 | for _, tc := range []struct { |
| 710 | name string |
| 711 | args []string |
| 712 | want string |
| 713 | }{ |
| 714 | {"domains no args", []string{"domains"}, "usage: carlos domains"}, |
| 715 | {"domains bogus verb", []string{"domains", "bogus"}, "usage: carlos domains"}, |
| 716 | // The no-app cases stop on the missing SESSION since the |
| 717 | // project-defaults spec (2026-08-13): -app resolves once a session |
| 718 | // exists, so with nothing configured the real blocker is named — |
| 719 | // not a usage line for a flag that is no longer required. |
| 720 | {"attach no app", []string{"domains", "attach", "tickets.example.com"}, "no deployment target configured"}, |
| 721 | {"attach no positional host", []string{"domains", "attach", "-app", "tito"}, "usage: carlos domains attach"}, |
| 722 | {"detach no app", []string{"domains", "detach", "tickets.example.com"}, "no deployment target configured"}, |
| 723 | {"detach no positional host", []string{"domains", "detach", "-app", "tito"}, "usage: carlos domains detach"}, |
| 724 | {"list no app", []string{"domains", "list"}, "no deployment target configured"}, |
| 725 | {"paths no app", []string{"domains", "paths", "ti.to"}, "no deployment target configured"}, |
| 726 | {"paths no positional host", []string{"domains", "paths", "-app", "tito"}, "usage: carlos domains paths"}, |
| 727 | } { |
| 728 | t.Run(tc.name, func(t *testing.T) { |
| 729 | out, err := run(tc.args...) |
| 730 | if err == nil { |
| 731 | t.Fatalf("carlos %s exited 0, want a nonzero (usage) exit:\n%s", strings.Join(tc.args, " "), out) |
| 732 | } |
| 733 | if !strings.Contains(out, tc.want) { |
| 734 | t.Fatalf("carlos %s output missing %q:\n%s", strings.Join(tc.args, " "), tc.want, out) |
| 735 | } |
| 736 | }) |
| 737 | } |
| 738 | } |
| 739 | |
| 740 | // TestRunDomainsAttachPlatformFlag: --platform asks for the operator's door |
| 741 | // (#209) and is sent only when named. |
| 742 | func TestRunDomainsAttachPlatformFlag(t *testing.T) { |
| 743 | fc := domainsCLIFixture(t) |
| 744 | runDomains([]string{"attach", "-app", "tito", "tickets.example.com"}) |
| 745 | if fc.lastAttachBody.Platform { |
| 746 | t.Fatalf("bare attach body = %+v, want platform unset", fc.lastAttachBody) |
| 747 | } |
| 748 | runDomains([]string{"attach", "-app", "tito", "--platform", "carloku.example.com"}) |
| 749 | if !fc.lastAttachBody.Platform || fc.lastAttachBody.Host != "carloku.example.com" { |
| 750 | t.Fatalf("--platform body = %+v, want platform true", fc.lastAttachBody) |
| 751 | } |
| 752 | } |
| 753 | |
| 754 | // TestRunDomainsInstanceCatchall: the star is the whole instruction — the |
| 755 | // body carries the host and nothing else — and the list marks the |
| 756 | // catch-all and the names derived from it. |
| 757 | func TestRunDomainsInstanceCatchall(t *testing.T) { |
| 758 | fc := domainsCLIFixture(t) |
| 759 | fc.attachCreated = true |
| 760 | buf := captureStdout(t) |
| 761 | runDomains([]string{"attach", "-app", "tito", "*.teams.example.com"}) |
| 762 | if b := fc.lastAttachBody; b.Host != "*.teams.example.com" || b.Route != "" || b.Wildcard != nil || b.Catchall != nil || b.Platform { |
| 763 | t.Fatalf("console saw body %+v", b) |
| 764 | } |
| 765 | if out := buf.String(); !strings.Contains(out, "attached *.teams.example.com -> <label>.teams.example.com for each instance") { |
| 766 | t.Fatalf("stdout = %q", out) |
| 767 | } |
| 768 | |
| 769 | buf = captureStdout(t) |
| 770 | runDomains([]string{"list", "-app", "tito"}) |
| 771 | var head, derived string |
| 772 | for _, l := range strings.Split(buf.String(), "\n") { |
| 773 | if strings.HasPrefix(l, "*.teams.example.com") { |
| 774 | head = l |
| 775 | } |
| 776 | if strings.HasPrefix(l, "acme.teams.example.com") { |
| 777 | derived = l |
| 778 | } |
| 779 | } |
| 780 | if !strings.Contains(head, "per-instance") || !strings.Contains(head, "instances") { |
| 781 | t.Fatalf("catch-all row = %q", head) |
| 782 | } |
| 783 | if !strings.Contains(derived, "acme.go.tito.io") || !strings.Contains(derived, "via *.teams.example.com") { |
| 784 | t.Fatalf("derived row = %q", derived) |
| 785 | } |
| 786 | |
| 787 | fc.detachRemoved = true |
| 788 | buf = captureStdout(t) |
| 789 | runDomains([]string{"detach", "-app", "tito", "*.teams.example.com"}) |
| 790 | if !strings.HasSuffix(fc.lastDetachPath, "/domains/*.teams.example.com") { |
| 791 | t.Fatalf("detach path = %s", fc.lastDetachPath) |
| 792 | } |
| 793 | } |
| 794 | |