carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
10package main
11
12import (
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.
27func 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.
59func 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.
81func 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.
98func 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.
118func 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.
158func 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).
184func 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").
203func 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.
240func 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.
264type 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
283func 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.
362func 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`.
383func 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").
406func 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.
423func 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
438func 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
450func 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
465func 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.
482func 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.
542func 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.
577func 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.
620func 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.
666func 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.
696func 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.
742func 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.
757func 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