carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3// The domains door (apidomains.go): attach, detach and list of the extra
4// hostnames an app's routes answer on (#151). The properties that matter, in
5// the order they'd hurt if lost: the gate is the instance family's, so an app
6// token bound elsewhere gets the byte-identical 404 and learns nothing; a
7// hostname someone else holds answers a NEUTRAL 409 that never names the
8// holder; detach is quiet in both directions (unknown host and another
9// owner's host read alike, and neither touches the claim or the serial); and
10// every effective write — and only an effective write — bumps control/serial
11// so boxes converge in seconds.
12
13import (
14 "context"
15 "encoding/json"
16 "errors"
17 "fmt"
18 "net/http"
19 "net/http/httptest"
20 "net/url"
21 "os"
22 "path/filepath"
23 "sort"
24 "strings"
25 "testing"
26 "time"
27
28 "github.com/carlosframework/platform/internal/certwarm"
29 "github.com/carlosframework/platform/internal/domains"
30 "github.com/carlosframework/platform/internal/edgesfile"
31 "github.com/carlosframework/platform/internal/provision"
32 "github.com/carlosframework/platform/internal/registry"
33 "github.com/carlosframework/platform/internal/release"
34 "github.com/carlosframework/platform/internal/status"
35 "github.com/carlosframework/platform/internal/svcrole"
36)
37
38func domainsURL(fx *consoleFixture, acct, app string) string {
39 return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/domains"
40}
41
42// attachBody is the door's request shape. route is always sent (empty means
43// "default it"), because an omitted field and an empty one must behave the
44// same and the tests exercise the empty one.
45func attachBody(host, route string) string {
46 b, err := json.Marshal(map[string]string{"host": host, "route": route})
47 if err != nil {
48 panic(err)
49 }
50 return string(b)
51}
52
53type domainAttachResp struct {
54 Host string `json:"host"`
55 Route string `json:"route"`
56 Created bool `json:"created"`
57}
58
59type domainDetachResp struct {
60 Host string `json:"host"`
61 Removed bool `json:"removed"`
62}
63
64type domainListResp struct {
65 Domains []struct {
66 Host string `json:"host"`
67 Route string `json:"route"`
68 Status string `json:"status"`
69 DNSStatus string `json:"dns_status"`
70 CertExpiresAt int64 `json:"cert_expires_at"`
71 Wildcard bool `json:"wildcard"`
72 Catchall bool `json:"catchall"`
73 DelegationStatus string `json:"delegation_status"`
74 } `json:"domains"`
75}
76
77// attachDomain posts one attach and returns the decoded answer, failing the
78// test on any status but the one asked for.
79func attachDomain(t *testing.T, fx *consoleFixture, token, host, route string) domainAttachResp {
80 t.Helper()
81 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), token, attachBody(host, route))
82 if code != http.StatusOK {
83 t.Fatalf("attach %s = %d %s, want 200", host, code, out)
84 }
85 var got domainAttachResp
86 if err := json.Unmarshal([]byte(out), &got); err != nil {
87 t.Fatalf("attach body %s: %v", out, err)
88 }
89 return got
90}
91
92// claimOf reads a claim straight from the bucket — the store side of every
93// assertion here, so a handler that answers 200 without writing is caught.
94func claimOf(t *testing.T, fx *consoleFixture, host string) *domains.Claim {
95 t.Helper()
96 c, err := domains.GetClaim(context.Background(), fx.rel, host)
97 if err != nil {
98 t.Fatalf("claim %s: %v", host, err)
99 }
100 return c
101}
102
103func claimAbsent(t *testing.T, fx *consoleFixture, host string) {
104 t.Helper()
105 if _, err := domains.GetClaim(context.Background(), fx.rel, host); !errors.Is(err, os.ErrNotExist) {
106 t.Fatalf("claim %s exists (err=%v), want none", host, err)
107 }
108}
109
110// TestAPIDomainAttachCreates: a member's token attaches a hostname to a
111// named route — 200, created true, and a claim object in the bucket carrying
112// the member as its author.
113func TestAPIDomainAttachCreates(t *testing.T) {
114 fx, member := instanceFixture(t, "code-dom-create")
115 got := attachDomain(t, fx, member, "tickets.example.com", "go.tito.io")
116 if got != (domainAttachResp{Host: "tickets.example.com", Route: "go.tito.io", Created: true}) {
117 t.Fatalf("attach answer = %+v, want the host/route echoed with created true", got)
118 }
119 c := claimOf(t, fx, "tickets.example.com")
120 if c.Schema != 1 || c.Account != "bab" || c.App != "titogo" || c.Route != "go.tito.io" {
121 t.Fatalf("claim = %+v, want schema 1 bab/titogo -> go.tito.io", *c)
122 }
123 if c.CreatedBy != "alice@keymail.dev" {
124 t.Fatalf("created_by = %q, want the member's address", c.CreatedBy)
125 }
126 if c.CreatedAt == "" {
127 t.Fatalf("claim has no created_at: %+v", *c)
128 }
129 // The hostname is normalized before it is stored: a caller shouting the
130 // host with a trailing dot claims the same object, not a second one.
131 if got := attachDomain(t, fx, member, "TICKETS.example.com.", "go.tito.io"); got.Created || got.Host != "tickets.example.com" {
132 t.Fatalf("re-attach of the same host in another case = %+v, want the normalized host, created false", got)
133 }
134}
135
136func TestAPIDomainAttachRecordsDescriptionAndCanonicalRoute(t *testing.T) {
137 fx, member := instanceFixture(t, "code-dom-message")
138 body := `{"host":"tickets.example.com","route":"go.tito.io","message":"attach the ticket checkout"}`
139 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, body)
140 if code != http.StatusOK {
141 t.Fatalf("attach = %d %s, want 200", code, out)
142 }
143 events, err := fx.st.Events(OpsAccountID, "titogo", 1)
144 if err != nil || len(events) != 1 {
145 t.Fatalf("Events = %+v, %v", events, err)
146 }
147 got := events[0]
148 if got.Action != "domain-attach" || got.TargetType != "instance" || got.Target != "go.tito.io" ||
149 got.Message != "attach the ticket checkout" || got.Detail != "attached hostname tickets.example.com -> go.tito.io (cli)" {
150 t.Fatalf("activity = %+v", got)
151 }
152}
153
154func TestAPIDomainAttachRejectsInvalidDescriptionBeforeWriting(t *testing.T) {
155 fx, member := instanceFixture(t, "code-dom-message-invalid")
156 body := `{"host":"tickets.example.com","route":"go.tito.io","message":"first\nsecond"}`
157 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, body)
158 if code != http.StatusBadRequest {
159 t.Fatalf("attach = %d %s, want 400", code, out)
160 }
161 claimAbsent(t, fx, "tickets.example.com")
162 if events, err := fx.st.Events(OpsAccountID, "titogo", 10); err != nil || len(events) != 0 {
163 t.Fatalf("activity after invalid message = %+v, %v", events, err)
164 }
165}
166
167// TestAPIDomainAttachAppToken: the app-held credential works on its own app
168// — and a token bound to a NEIGHBOURING app in the same account meets the
169// family's byte-identical 404 (apiInstancePrincipal's contract), never a
170// hint that the path exists.
171func TestAPIDomainAttachAppToken(t *testing.T) {
172 fx, _ := instanceFixture(t, "code-dom-apptoken")
173 now := time.Now()
174 if _, err := fx.st.CreateAppIn(OpsAccountID, "other", "alice@keymail.dev", now); err != nil {
175 t.Fatal(err)
176 }
177 mine, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", now)
178 if err != nil {
179 t.Fatal(err)
180 }
181 foreign, err := fx.st.NewAppToken(OpsAccountID, "other", "alice@keymail.dev", "signup", now)
182 if err != nil {
183 t.Fatal(err)
184 }
185 got := attachDomain(t, fx, mine, "tickets.example.com", "go.tito.io")
186 if !got.Created {
187 t.Fatalf("app token attach = %+v, want created true", got)
188 }
189 if c := claimOf(t, fx, "tickets.example.com"); c.CreatedBy != "app:bab/titogo" {
190 t.Fatalf("created_by = %q, want the app principal app:bab/titogo", c.CreatedBy)
191 }
192 want := canonicalNotFound(t)
193 for _, c := range []struct{ what, method, url, body string }{
194 {"foreign app token attach", http.MethodPost, domainsURL(fx, "bab", "titogo"), attachBody("other.example.com", "go.tito.io")},
195 {"foreign app token list", http.MethodGet, domainsURL(fx, "bab", "titogo"), ""},
196 {"foreign app token detach", http.MethodDelete, domainsURL(fx, "bab", "titogo") + "/tickets.example.com", ""},
197 } {
198 code, h, out := rawReq(t, c.method, c.url, foreign, c.body)
199 assertNotFound(t, c.what, want, code, h, out)
200 }
201 // The refused calls changed nothing: no new claim, and the one that
202 // existed is still there.
203 claimAbsent(t, fx, "other.example.com")
204 claimOf(t, fx, "tickets.example.com")
205}
206
207// TestAPIDomainAttachIdempotent: attach is the caller's GOAL, not an event —
208// a second identical attach is 200 with created false, and an attach naming a
209// different route converges the claim in place rather than refusing it.
210func TestAPIDomainAttachIdempotent(t *testing.T) {
211 fx, member := instanceFixture(t, "code-dom-idem")
212 if got := attachDomain(t, fx, member, "tickets.example.com", "go.tito.io"); !got.Created {
213 t.Fatalf("first attach = %+v, want created true", got)
214 }
215 if got := attachDomain(t, fx, member, "tickets.example.com", "go.tito.io"); got.Created {
216 t.Fatalf("second identical attach = %+v, want created false", got)
217 }
218 if got := attachDomain(t, fx, member, "tickets.example.com", "two.go.tito.io"); got.Created || got.Route != "two.go.tito.io" {
219 t.Fatalf("re-attach to another route = %+v, want created false on the new route", got)
220 }
221 if c := claimOf(t, fx, "tickets.example.com"); c.Route != "two.go.tito.io" {
222 t.Fatalf("claim route = %q, want the converged two.go.tito.io", c.Route)
223 }
224}
225
226// TestAPIDomainAttachConflictNeutral: a hostname another account holds is a
227// 409 whose body says exactly one thing — never who holds it, never whether
228// the holder is in this deployment at all.
229func TestAPIDomainAttachConflictNeutral(t *testing.T) {
230 fx, member := instanceFixture(t, "code-dom-conflict")
231 if _, _, err := domains.AttachClaim(context.Background(), fx.rel, domains.Claim{
232 Host: "tickets.example.com", Account: "cdef", App: "widgets",
233 Route: "widgets.example.net", CreatedAt: time.Now().UTC().Format(time.RFC3339),
234 }); err != nil {
235 t.Fatal(err)
236 }
237 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
238 attachBody("tickets.example.com", "go.tito.io"))
239 if code != http.StatusConflict {
240 t.Fatalf("attach of a held hostname = %d %s, want 409", code, out)
241 }
242 if got := strings.TrimSpace(out); got != `{"error":"hostname unavailable"}` {
243 t.Fatalf("409 body = %s, want the neutral {\"error\":\"hostname unavailable\"}", got)
244 }
245 // The holder's claim is untouched by the refusal.
246 if c := claimOf(t, fx, "tickets.example.com"); c.Account != "cdef" || c.App != "widgets" {
247 t.Fatalf("claim after a refused attach = %+v, want the holder's own", *c)
248 }
249}
250
251// TestAPIDomainAttachValidation: every hostname this door refuses on a
252// deployment that reserves platform domains (the flagship, carloku), each
253// with nothing written behind it: malformed, bare-label, under a configured
254// deny domain, under the deployment's apps domain, the console's own origin
255// host and a name under it, and a RETIRED console origin
256// (CONSOLE_REDIRECT_HOSTS, which this console still answers on and 301s
257// away, such as tito's carlos.tito.io).
258func TestAPIDomainAttachValidation(t *testing.T) {
259 fx, member := instanceFixture(t, "code-dom-validate")
260 fx.srv.cfg.DenyDomains = []string{"", "denied.test"}
261 fx.srv.cfg.AppsDomain = "oncarlos.test"
262 fx.srv.cfg.RedirectHosts = []string{"", "Carlos.Tito.Test:8443"}
263 fx.srv.cfg.ReservePlatformDomains = true
264 origin, err := url.Parse(fx.srv.cfg.Origin)
265 if err != nil {
266 t.Fatal(err)
267 }
268 for _, host := range []string{
269 "Bad_Host!", // not a hostname at all
270 "nodots", // a bare label is never a public hostname
271 "x.denied.test", // under a CONSOLE_DENY_DOMAINS entry
272 "denied.test", // the deny entry itself
273 "one.bab.oncarlos.test", // platform apex space (spec §5)
274 "oncarlos.test", // the apps domain itself
275 origin.Hostname(), // the console's own origin host
276 "x." + origin.Hostname(), // and anything under it
277 "carlos.tito.test", // a retired console origin, normalized as ServeHTTP normalizes it
278 "old.carlos.tito.test", // and anything under it
279 } {
280 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
281 attachBody(host, "go.tito.io"))
282 if code != http.StatusBadRequest {
283 t.Errorf("attach %q = %d %s, want 400", host, code, out)
284 }
285 claimAbsent(t, fx, strings.ToLower(host))
286 }
287 // An empty deny entry matches nothing: the ordinary hostname still goes
288 // through with "" in the list.
289 if got := attachDomain(t, fx, member, "tickets.example.com", "go.tito.io"); !got.Created {
290 t.Fatalf("ordinary attach with an empty deny entry present = %+v, want created true", got)
291 }
292}
293
294// TestAPIDomainAttachUnreservedDefault: ReservePlatformDomains is off by
295// default, which is the self-hosted shape. There the console, the apps
296// domain and the custom domains usually share one zone that one person
297// owns. The console's own hostnames and the operator's CONSOLE_DENY_DOMAINS
298// are still refused. The apps domain, and names under the console's
299// hostnames, are not.
300func TestAPIDomainAttachUnreservedDefault(t *testing.T) {
301 fx, member := instanceFixture(t, "code-dom-unreserved")
302 origin, err := url.Parse(fx.srv.cfg.Origin)
303 if err != nil {
304 t.Fatal(err)
305 }
306 fx.srv.cfg.DenyDomains = []string{"denied.test"}
307 fx.srv.cfg.AppsDomain = origin.Hostname() // the console sits at the apps-domain apex
308 fx.srv.cfg.RedirectHosts = []string{"Carlos.Tito.Test:8443"}
309
310 for _, host := range []string{
311 "x.denied.test", // the operator's own list still applies
312 origin.Hostname(), // the console's own origin host
313 "carlos.tito.test", // a retired console origin
314 } {
315 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
316 attachBody(host, "go.tito.io"))
317 if code != http.StatusBadRequest {
318 t.Errorf("attach %q = %d %s, want 400", host, code, out)
319 }
320 claimAbsent(t, fx, host)
321 }
322 for _, host := range []string{
323 "blog." + origin.Hostname(), // under the console host AND the apps domain
324 "one.bab." + origin.Hostname(), // platform-shaped, but no route holds it
325 "old.carlos.tito.test", // under a retired console origin
326 } {
327 if got := attachDomain(t, fx, member, host, "go.tito.io"); !got.Created {
328 t.Errorf("attach %q unreserved = %+v, want created true", host, got)
329 }
330 }
331}
332
333// TestAPIDomainAttachRouteDefault: the route may be omitted when there is
334// exactly one thing it could mean. Two instance records make it ambiguous,
335// and ambiguity names the flag that resolves it rather than guessing.
336func TestAPIDomainAttachRouteDefault(t *testing.T) {
337 fx, member := instanceFixture(t, "code-dom-route")
338 ctx := context.Background()
339 rec := provision.Record{Schema: 1, Host: "one.go.tito.io", Slug: "one", App: "titogo",
340 Account: "bab", Channel: "stable", CreatedAt: time.Now().UTC()}
341 if err := provision.CreateRecord(ctx, fx.rel, rec); err != nil {
342 t.Fatal(err)
343 }
344 if got := attachDomain(t, fx, member, "tickets.example.com", ""); got.Route != "one.go.tito.io" {
345 t.Fatalf("attach with no route = %+v, want the app's one instance as the route", got)
346 }
347 if c := claimOf(t, fx, "tickets.example.com"); c.Route != "one.go.tito.io" {
348 t.Fatalf("claim route = %q, want one.go.tito.io", c.Route)
349 }
350 rec.Host, rec.Slug = "two.go.tito.io", "two"
351 if err := provision.CreateRecord(ctx, fx.rel, rec); err != nil {
352 t.Fatal(err)
353 }
354 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
355 attachBody("other.example.com", ""))
356 if code != http.StatusBadRequest {
357 t.Fatalf("ambiguous attach = %d %s, want 400", code, out)
358 }
359 if !strings.Contains(out, "-route") {
360 t.Fatalf("ambiguous attach body = %s, want it to name the --route flag", out)
361 }
362 claimAbsent(t, fx, "other.example.com")
363}
364
365// TestAPIDomainDetachQuiet: detach answers the same 200 shape three ways —
366// the claim it removed, a hostname nobody claimed, and one another account
367// holds — so a caller cannot use detach to discover who owns a hostname.
368// Only the first of the three actually removes anything.
369func TestAPIDomainDetachQuiet(t *testing.T) {
370 fx, member := instanceFixture(t, "code-dom-detach")
371 attachDomain(t, fx, member, "tickets.example.com", "go.tito.io")
372 if _, _, err := domains.AttachClaim(context.Background(), fx.rel, domains.Claim{
373 Host: "theirs.example.com", Account: "cdef", App: "widgets",
374 Route: "widgets.example.net", CreatedAt: time.Now().UTC().Format(time.RFC3339),
375 }); err != nil {
376 t.Fatal(err)
377 }
378 detach := func(host string) domainDetachResp {
379 t.Helper()
380 code, _, out := rawReq(t, http.MethodDelete, domainsURL(fx, "bab", "titogo")+"/"+host, member, "")
381 if code != http.StatusOK {
382 t.Fatalf("detach %s = %d %s, want 200", host, code, out)
383 }
384 var got domainDetachResp
385 if err := json.Unmarshal([]byte(out), &got); err != nil {
386 t.Fatalf("detach body %s: %v", out, err)
387 }
388 return got
389 }
390 if got := detach("tickets.example.com"); got != (domainDetachResp{Host: "tickets.example.com", Removed: true}) {
391 t.Fatalf("detach of an own claim = %+v, want removed true", got)
392 }
393 claimAbsent(t, fx, "tickets.example.com")
394 if got := detach("tickets.example.com"); got.Removed {
395 t.Fatalf("second detach = %+v, want removed false", got)
396 }
397 if got := detach("never.example.com"); got != (domainDetachResp{Host: "never.example.com", Removed: false}) {
398 t.Fatalf("detach of an unknown hostname = %+v, want the same shape, removed false", got)
399 }
400 if got := detach("theirs.example.com"); got != (domainDetachResp{Host: "theirs.example.com", Removed: false}) {
401 t.Fatalf("detach of another account's claim = %+v, want the same quiet answer", got)
402 }
403 if c := claimOf(t, fx, "theirs.example.com"); c.Account != "cdef" || c.App != "widgets" {
404 t.Fatalf("another account's claim after a detach = %+v, want it intact", *c)
405 }
406 // Traversal defence: a {host} arrives percent-decoded, so a path with
407 // separators in it reaches the handler. It is refused on shape, before
408 // anything builds a bucket key from it.
409 for _, bad := range []string{"bad_host", "..%2F..%2Finstances.json"} {
410 if code, _, out := rawReq(t, http.MethodDelete,
411 domainsURL(fx, "bab", "titogo")+"/"+bad, member, ""); code != http.StatusBadRequest {
412 t.Errorf("detach %q = %d %s, want 400", bad, code, out)
413 }
414 }
415}
416
417// TestAPIDomainList: this app's claims only, each joined to the box readings
418// sweep — a hostname a live box reports carries its DNS/cert columns and
419// reads "live"; one no live box reports reads "pending", including one only a
420// STALE box still remembers.
421func TestAPIDomainList(t *testing.T) {
422 fx, member := instanceFixture(t, "code-dom-list")
423 ctx := context.Background()
424 now := time.Now()
425 if _, err := fx.st.CreateAppIn(OpsAccountID, "other", "alice@keymail.dev", now); err != nil {
426 t.Fatal(err)
427 }
428 attachDomain(t, fx, member, "live.example.com", "go.tito.io")
429 attachDomain(t, fx, member, "pending.example.com", "go.tito.io")
430 attachDomain(t, fx, member, "stale.example.com", "go.tito.io")
431 if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{
432 Host: "elsewhere.example.com", Account: "bab", App: "other",
433 Route: "other.go.tito.io", CreatedAt: now.UTC().Format(time.RFC3339),
434 }); err != nil {
435 t.Fatal(err)
436 }
437 if err := status.WriteBox(ctx, fx.rel, status.Box{
438 GeneratedAt: now, Label: "ada-reporting", Region: "eu-west-1",
439 Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{
440 "live.example.com": {Route: "go.tito.io", DNSStatus: "ok", CertExpiresAt: 1786000000},
441 "elsewhere.example.com": {Route: "other.go.tito.io", DNSStatus: "ok"},
442 }},
443 }); err != nil {
444 t.Fatal(err)
445 }
446 // A box that stopped reporting drops OUT of the join (the stagedVersions
447 // rule): its last-known reading must not go on answering as if current.
448 if err := status.WriteBox(ctx, fx.rel, status.Box{
449 GeneratedAt: now.Add(-2 * time.Hour), Label: "bob-silent", Region: "eu-west-1",
450 Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{
451 "stale.example.com": {Route: "go.tito.io", DNSStatus: "ok", CertExpiresAt: 1786000000},
452 }},
453 }); err != nil {
454 t.Fatal(err)
455 }
456 code, _, out := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "")
457 if code != http.StatusOK {
458 t.Fatalf("list = %d %s, want 200", code, out)
459 }
460 var got domainListResp
461 if err := json.Unmarshal([]byte(out), &got); err != nil {
462 t.Fatalf("list body %s: %v", out, err)
463 }
464 if len(got.Domains) != 3 {
465 t.Fatalf("list = %+v, want exactly this app's three claims", got.Domains)
466 }
467 byHost := map[string]struct {
468 Host string `json:"host"`
469 Route string `json:"route"`
470 Status string `json:"status"`
471 DNSStatus string `json:"dns_status"`
472 CertExpiresAt int64 `json:"cert_expires_at"`
473 Wildcard bool `json:"wildcard"`
474 Catchall bool `json:"catchall"`
475 DelegationStatus string `json:"delegation_status"`
476 }{}
477 for _, d := range got.Domains {
478 byHost[d.Host] = d
479 }
480 if _, ok := byHost["elsewhere.example.com"]; ok {
481 t.Fatalf("another app's claim is in this app's listing: %+v", got.Domains)
482 }
483 live := byHost["live.example.com"]
484 if live.Status != "live" || live.DNSStatus != "ok" || live.CertExpiresAt != 1786000000 || live.Route != "go.tito.io" {
485 t.Fatalf("reported hostname = %+v, want live with its DNS/cert columns", live)
486 }
487 if p := byHost["pending.example.com"]; p.Status != "pending" || p.DNSStatus != "" || p.CertExpiresAt != 0 {
488 t.Fatalf("unreported hostname = %+v, want pending with no readings", p)
489 }
490 if s := byHost["stale.example.com"]; s.Status != "pending" || s.DNSStatus != "" {
491 t.Fatalf("hostname only a stale box reports = %+v, want pending", s)
492 }
493}
494
495// TestAPIDomainListSuppressesGuidanceForUnresolvedDNS pins the honesty rule
496// this task adds: "unresolved" means the DNS LOOKUP FAILED (our resolver),
497// not that the member's record is missing or wrong. The door must not print
498// "point your DNS here" guidance for it — that would blame the member for
499// our outage. "elsewhere" (a real, wrong answer) still earns guidance, so
500// the suppression is specific to "unresolved" and not a blanket regression.
501func TestAPIDomainListSuppressesGuidanceForUnresolvedDNS(t *testing.T) {
502 fx, member := instanceFixture(t, "code-dom-unresolved")
503 ctx := context.Background()
504 now := time.Now()
505 attachDomain(t, fx, member, "broken.example.com", "go.tito.io")
506 attachDomain(t, fx, member, "wrong.example.com", "go.tito.io")
507 if err := status.WriteBox(ctx, fx.rel, status.Box{
508 GeneratedAt: now, Label: "ada-reporting", Region: "eu-west-1",
509 Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{
510 "broken.example.com": {Route: "go.tito.io", DNSStatus: "unresolved"},
511 "wrong.example.com": {Route: "go.tito.io", DNSStatus: "elsewhere"},
512 }},
513 }); err != nil {
514 t.Fatal(err)
515 }
516 code, _, out := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "")
517 if code != http.StatusOK {
518 t.Fatalf("list = %d %s, want 200", code, out)
519 }
520 var got struct {
521 Domains []struct {
522 Host string `json:"host"`
523 DNSStatus string `json:"dns_status"`
524 Guidance string `json:"guidance"`
525 } `json:"domains"`
526 }
527 if err := json.Unmarshal([]byte(out), &got); err != nil {
528 t.Fatalf("list body %s: %v", out, err)
529 }
530 byHost := map[string]string{}
531 for _, d := range got.Domains {
532 if d.DNSStatus != map[string]string{"broken.example.com": "unresolved", "wrong.example.com": "elsewhere"}[d.Host] {
533 t.Fatalf("%s dns_status = %q, unexpected", d.Host, d.DNSStatus)
534 }
535 byHost[d.Host] = d.Guidance
536 }
537 if g := byHost["broken.example.com"]; g != "" {
538 t.Fatalf("unresolved host got guidance %q, want none — our lookup failure is not the member's problem to fix", g)
539 }
540 if g := byHost["wrong.example.com"]; g == "" {
541 t.Fatal("a real wrong-answer (elsewhere) host must still get point-your-DNS guidance")
542 }
543}
544
545// domainEvents counts this app's audit rows whose action starts "domain-".
546func domainEvents(t *testing.T, fx *consoleFixture) int {
547 t.Helper()
548 events, err := fx.st.Events(OpsAccountID, "titogo", 50)
549 if err != nil {
550 t.Fatal(err)
551 }
552 n := 0
553 for _, e := range events {
554 if strings.HasPrefix(e.Action, "domain-") {
555 n++
556 }
557 }
558 return n
559}
560
561// TestAPIDomainSerialBump: boxes converge off control/serial, so every
562// EFFECTIVE write announces itself — and the two no-ops, a re-attach that
563// changed nothing and a detach that removed nothing, announce nothing and
564// write no audit row. That matters more than it looks: titogo's router
565// re-attaches its hostnames on every reload pass, so redundant attaches are
566// the STEADY STATE — a bump per pass would keep the whole fleet converging
567// onto nothing, and an audit row per pass would bury the tenant's log.
568func TestAPIDomainSerialBump(t *testing.T) {
569 fx, member := instanceFixture(t, "code-dom-serial")
570 if got := serial(t, fx); got != 0 {
571 t.Fatalf("serial before any domain write = %d, want 0", got)
572 }
573 attachDomain(t, fx, member, "tickets.example.com", "go.tito.io")
574 if got := serial(t, fx); got != 1 {
575 t.Fatalf("serial after an attach = %d, want 1", got)
576 }
577 if got := domainEvents(t, fx); got != 1 {
578 t.Fatalf("audit rows after an attach = %d, want 1", got)
579 }
580 // The router's steady state: the same attach again, changing nothing.
581 if got := attachDomain(t, fx, member, "tickets.example.com", "go.tito.io"); got.Created {
582 t.Fatalf("no-op re-attach = %+v, want created false", got)
583 }
584 if got := serial(t, fx); got != 1 {
585 t.Fatalf("serial after a no-op re-attach = %d, want it unmoved at 1", got)
586 }
587 if got := domainEvents(t, fx); got != 1 {
588 t.Fatalf("audit rows after a no-op re-attach = %d, want it unmoved at 1", got)
589 }
590 // A converge IS a write: new route, so the fleet hears about it.
591 if got := attachDomain(t, fx, member, "tickets.example.com", "two.go.tito.io"); got.Created {
592 t.Fatalf("converge attach = %+v, want created false", got)
593 }
594 if got := serial(t, fx); got != 2 {
595 t.Fatalf("serial after a converge = %d, want 2", got)
596 }
597 if got := domainEvents(t, fx); got != 2 {
598 t.Fatalf("audit rows after a converge = %d, want 2", got)
599 }
600 if code, _, out := rawReq(t, http.MethodDelete,
601 domainsURL(fx, "bab", "titogo")+"/tickets.example.com", member, ""); code != http.StatusOK {
602 t.Fatalf("detach = %d %s, want 200", code, out)
603 }
604 if got := serial(t, fx); got != 3 {
605 t.Fatalf("serial after an effective detach = %d, want 3", got)
606 }
607 if got := domainEvents(t, fx); got != 3 {
608 t.Fatalf("audit rows after an effective detach = %d, want 3", got)
609 }
610 if code, _, out := rawReq(t, http.MethodDelete,
611 domainsURL(fx, "bab", "titogo")+"/never.example.com", member, ""); code != http.StatusOK {
612 t.Fatalf("no-op detach = %d %s, want 200", code, out)
613 }
614 if got := serial(t, fx); got != 3 {
615 t.Fatalf("serial after a no-op detach = %d, want it unmoved at 3", got)
616 }
617 if got := domainEvents(t, fx); got != 3 {
618 t.Fatalf("audit rows after a no-op detach = %d, want it unmoved at 3", got)
619 }
620}
621
622// attachBodyKind is attachBody with the deploy extras (#82): kind and/or
623// channel ride the same POST body; empty fields are simply absent, matching
624// what the client sends (omitempty).
625func attachBodyKind(host, route, kind, channel string) string {
626 m := map[string]string{"host": host}
627 if route != "" {
628 m["route"] = route
629 }
630 if kind != "" {
631 m["kind"] = kind
632 }
633 if channel != "" {
634 m["channel"] = channel
635 }
636 b, err := json.Marshal(m)
637 if err != nil {
638 panic(err)
639 }
640 return string(b)
641}
642
643// TestAPIDomainAttachKindValidation pins the two 400s the kind extension
644// adds: route and kind together (an alias target and a first-route mint are
645// two different instructions), and any kind but "static" (nothing else can
646// be minted from a domain attach). Neither writes a claim.
647func TestAPIDomainAttachKindValidation(t *testing.T) {
648 fx, member := instanceFixture(t, "code-dom-kind-400")
649 for name, body := range map[string]string{
650 "route and kind together": attachBodyKind("both.example.com", "go.tito.io", "static", ""),
651 "kind other than static": attachBodyKind("both.example.com", "", "binary", ""),
652 } {
653 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, body)
654 if code != http.StatusBadRequest {
655 t.Errorf("%s = %d %s, want 400", name, code, out)
656 }
657 claimAbsent(t, fx, "both.example.com")
658 }
659}
660
661// TestAPIDomainAttachKindStaticFirstRoute: kind=static against an app with
662// no ready route runs the shared attach core's first-route branch — the
663// claim (empty stored route) AND a declared static provision record land,
664// and the answer's route is the host itself, the primary the record just
665// declared. This is `carlos deploy -kind static -host <h>`'s server half.
666func TestAPIDomainAttachKindStaticFirstRoute(t *testing.T) {
667 fx, member := instanceFixture(t, "code-dom-kind-first")
668 // The fixture registry holds no titogo routes, so the core has nothing
669 // to alias; the first-route branch needs something shipped to resolve
670 // the record's channel from.
671 shipVersion(t, fx, "titogo", "v1")
672 seedChannel(t, fx, "titogo", "canary/live", "v1")
673 const host = "fresh.example.com"
674
675 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
676 `{"host":"fresh.example.com","kind":"static","message":"publish the new site"}`)
677 if code != http.StatusOK {
678 t.Fatalf("attach = %d %s, want 200", code, out)
679 }
680 var got domainAttachResp
681 if err := json.Unmarshal([]byte(out), &got); err != nil {
682 t.Fatalf("attach body %s: %v", out, err)
683 }
684 if got != (domainAttachResp{Host: host, Route: host, Created: true}) {
685 t.Fatalf("attach answer = %+v, want route == host (the first-route primary) and created true", got)
686 }
687 c := claimOf(t, fx, host)
688 if c.Route != "" || c.Account != "bab" || c.App != "titogo" {
689 t.Fatalf("claim = %+v, want a first-route claim (empty stored route) for bab/titogo", *c)
690 }
691 rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", host)
692 if err != nil {
693 t.Fatalf("record: %v, want the declared static record", err)
694 }
695 if rec.Kind != "static" || rec.Channel != "canary/live" || rec.Slug != "" {
696 t.Fatalf("record = %+v, want a static record on canary/live", *rec)
697 }
698 events, err := fx.st.Events(OpsAccountID, "titogo", 1)
699 if err != nil || len(events) != 1 {
700 t.Fatalf("Events = %+v, %v", events, err)
701 }
702 if got := events[0]; got.Action != "domain-attach" || got.TargetType != "instance" || got.Target != host ||
703 got.Message != "publish the new site" || got.Detail != host+" (first route, canary/live)" {
704 t.Fatalf("activity = %+v", got)
705 }
706}
707
708// TestAPIDomainAttachKindStaticWithReadyRouteAliases: the same kind=static
709// body against an app that DOES hold a ready route is an ordinary alias —
710// the core answers its own question and nothing static is minted: the claim
711// stores the ready route, the answer reports it, and no provision record
712// appears at the host's key.
713func TestAPIDomainAttachKindStaticWithReadyRouteAliases(t *testing.T) {
714 fx, member := instanceFixture(t, "code-dom-kind-alias")
715 fx.srv.cfg.Registry = attachRegistry(t,
716 registry.Route{Host: "titogo.bab.oncarlos.test", Kind: "static", App: "titogo",
717 Account: "bab", Channel: "canary/live", Status: "ready"},
718 )
719 const host = "alias.example.com"
720
721 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
722 attachBodyKind(host, "", "static", ""))
723 if code != http.StatusOK {
724 t.Fatalf("attach = %d %s, want 200", code, out)
725 }
726 var got domainAttachResp
727 if err := json.Unmarshal([]byte(out), &got); err != nil {
728 t.Fatalf("attach body %s: %v", out, err)
729 }
730 if got != (domainAttachResp{Host: host, Route: "titogo.bab.oncarlos.test", Created: true}) {
731 t.Fatalf("attach answer = %+v, want the ready route as the alias target", got)
732 }
733 if c := claimOf(t, fx, host); c.Route != "titogo.bab.oncarlos.test" {
734 t.Fatalf("claim = %+v, want the alias claim naming the ready route", *c)
735 }
736 if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", host); !errors.Is(err, os.ErrNotExist) {
737 t.Fatalf("record: %v, want NONE — an alias attach must not mint a static record", err)
738 }
739}
740
741// ---- wildcard/catchall capability door (Task 5) ----------------------------
742
743// TestAttachWildcardCNAMEGate: the attach body's optional wildcard/catchall
744// pointers (Task 5). Enabling wildcard is gated on two things — a configured
745// delegation zone, and a live _acme-challenge CNAME delegating to it — and
746// catchall is refused without wildcard, the same ErrCatchallNeedsWildcard
747// domains.SetClaimCapabilities itself enforces. Every refusal here leaves the
748// stored claim exactly as it was.
749func TestAttachWildcardCNAMEGate(t *testing.T) {
750 const zone = "acme.oncarlos.com"
751 const host = "woodstar.app"
752 wantCNAME := "_acme-challenge." + host + "." + zone + "."
753
754 t.Run("missing CNAME refuses with the record to create", func(t *testing.T) {
755 fx, member := instanceFixture(t, "code-dom-wc-missing")
756 fx.srv.delegationZone = zone
757 fx.srv.lookupCNAME = func(context.Context, string) (string, error) { return "", errors.New("not found") }
758 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
759 `{"host":"`+host+`","route":"go.tito.io","wildcard":true}`)
760 if code != http.StatusUnprocessableEntity {
761 t.Fatalf("attach with a missing CNAME = %d %s, want 422", code, out)
762 }
763 // Decoded, not a raw substring match: json.Encoder HTML-escapes ">"
764 // to > by default, which json.Unmarshal undoes transparently
765 // but a literal strings.Contains against the raw wire bytes would not.
766 var got struct {
767 Error string `json:"error"`
768 }
769 if err := json.Unmarshal([]byte(out), &got); err != nil {
770 t.Fatalf("422 body %s: %v", out, err)
771 }
772 want := "create CNAME _acme-challenge." + host + " -> _acme-challenge." + host + "." + zone + ", then retry"
773 if got.Error != want {
774 t.Fatalf("422 error = %q, want %q", got.Error, want)
775 }
776 if c := claimOf(t, fx, host); c.Wildcard {
777 t.Fatalf("claim after a refused wildcard enable = %+v, want Wildcard still false", *c)
778 }
779 })
780
781 t.Run("correct CNAME enables wildcard and bumps the serial", func(t *testing.T) {
782 fx, member := instanceFixture(t, "code-dom-wc-ok")
783 fx.srv.delegationZone = zone
784 fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) {
785 if name == "_acme-challenge."+host {
786 return wantCNAME, nil
787 }
788 return "", errors.New("not found")
789 }
790 before := serial(t, fx)
791 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
792 `{"host":"`+host+`","route":"go.tito.io","wildcard":true}`)
793 if code != http.StatusOK {
794 t.Fatalf("attach with a correct CNAME = %d %s, want 200", code, out)
795 }
796 if c := claimOf(t, fx, host); !c.Wildcard {
797 t.Fatalf("claim after an accepted wildcard enable = %+v, want Wildcard true", *c)
798 }
799 if got := serial(t, fx); got <= before {
800 t.Fatalf("serial after a wildcard enable = %d, want it to have moved past %d", got, before)
801 }
802 })
803
804 // IMPORTANT-1 (review): DNS names compare case-insensitively (RFC 4343)
805 // and a resolver answers in the record's own authored case, not the
806 // zone's. A customer who typed their CNAME target in mixed case must
807 // still delegate, not read as a permanently broken/missing record.
808 t.Run("mixed-case CNAME target still delegates", func(t *testing.T) {
809 fx, member := instanceFixture(t, "code-dom-wc-mixedcase")
810 fx.srv.delegationZone = zone
811 mixedCase := "_ACME-Challenge." + host + "." + strings.ToUpper(zone) + "."
812 fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) {
813 if name == "_acme-challenge."+host {
814 return mixedCase, nil
815 }
816 return "", errors.New("not found")
817 }
818 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
819 `{"host":"`+host+`","route":"go.tito.io","wildcard":true}`)
820 if code != http.StatusOK {
821 t.Fatalf("attach with a mixed-case CNAME target = %d %s, want 200", code, out)
822 }
823 if c := claimOf(t, fx, host); !c.Wildcard {
824 t.Fatalf("claim after a mixed-case CNAME enable = %+v, want Wildcard true", *c)
825 }
826 })
827
828 // IMPORTANT-2 (review): re-sending wildcard:true for a claim that
829 // already has it must not re-run the CNAME lookup at all — proven here
830 // by a stub that always errors (simulating a resolver blip) plus a call
831 // counter: if the gate wrongly re-checked on every idempotent re-attach,
832 // this would 422 instead of succeeding, and the counter would be
833 // nonzero.
834 t.Run("re-sending wildcard true on an already-wildcard claim skips the lookup", func(t *testing.T) {
835 fx, member := instanceFixture(t, "code-dom-wc-idempotent")
836 fx.srv.delegationZone = zone
837 calls := 0
838 fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) {
839 if name == "_acme-challenge."+host {
840 return wantCNAME, nil
841 }
842 return "", errors.New("not found")
843 }
844 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
845 `{"host":"`+host+`","route":"go.tito.io","wildcard":true}`)
846 if code != http.StatusOK {
847 t.Fatalf("seed wildcard enable = %d %s, want 200", code, out)
848 }
849 // Now break the resolver entirely (a blip) and count every call.
850 fx.srv.lookupCNAME = func(context.Context, string) (string, error) {
851 calls++
852 return "", errors.New("resolver unavailable")
853 }
854 code, _, out = rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
855 `{"host":"`+host+`","route":"go.tito.io","wildcard":true}`)
856 if code != http.StatusOK {
857 t.Fatalf("re-sending wildcard:true on an already-wildcard claim = %d %s, want 200 (no re-check)", code, out)
858 }
859 if calls != 0 {
860 t.Fatalf("lookupCNAME was called %d times on an idempotent re-attach, want 0", calls)
861 }
862 if c := claimOf(t, fx, host); !c.Wildcard {
863 t.Fatalf("claim after the idempotent re-attach = %+v, want Wildcard still true", *c)
864 }
865 })
866
867 // IMPORTANT-3 (review): the capability write's own ErrHostUnavailable
868 // branch (setDomainCapabilities, apidomains.go), forced without a new
869 // test seam: the lookupCNAME stub DETACHES the claim as a side effect
870 // (simulating the claim changing hands between the attach write above
871 // and SetClaimCapabilities's own read) and THEN returns the correct
872 // CNAME, so the gate passes but SetClaimCapabilities's read finds
873 // os.ErrNotExist and returns ErrHostUnavailable — which must answer the
874 // SAME byte-identical 404 every other non-owner refusal does.
875 t.Run("claim vanishing between attach and capability write answers the byte-identical 404", func(t *testing.T) {
876 fx, member := instanceFixture(t, "code-dom-wc-vanish")
877 fx.srv.delegationZone = zone
878 fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) {
879 if _, err := domains.DetachClaim(context.Background(), fx.rel, host, "bab", "titogo"); err != nil {
880 t.Fatal(err)
881 }
882 return wantCNAME, nil
883 }
884 want := canonicalNotFound(t)
885 code, h, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
886 `{"host":"`+host+`","route":"go.tito.io","wildcard":true}`)
887 assertNotFound(t, "capability write after the claim vanished mid-request", want, code, h, out)
888 claimAbsent(t, fx, host)
889 })
890
891 t.Run("catchall without wildcard refuses and stores nothing", func(t *testing.T) {
892 fx, member := instanceFixture(t, "code-dom-wc-catchall")
893 fx.srv.delegationZone = zone
894 fx.srv.lookupCNAME = func(context.Context, string) (string, error) { return "", errors.New("not found") }
895 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
896 `{"host":"`+host+`","route":"go.tito.io","catchall":true}`)
897 if code != http.StatusUnprocessableEntity {
898 t.Fatalf("attach with catchall and no wildcard = %d %s, want 422", code, out)
899 }
900 if !strings.Contains(out, "catchall requires wildcard") {
901 t.Fatalf("422 body = %s, want it to mention catchall requires wildcard", out)
902 }
903 if c := claimOf(t, fx, host); c.Wildcard || c.Catchall {
904 t.Fatalf("claim after a refused catchall = %+v, want both false", *c)
905 }
906 })
907
908 t.Run("wildcard and catchall together with a correct CNAME store both", func(t *testing.T) {
909 fx, member := instanceFixture(t, "code-dom-wc-both")
910 fx.srv.delegationZone = zone
911 fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) {
912 if name == "_acme-challenge."+host {
913 return wantCNAME, nil
914 }
915 return "", errors.New("not found")
916 }
917 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
918 `{"host":"`+host+`","route":"go.tito.io","wildcard":true,"catchall":true}`)
919 if code != http.StatusOK {
920 t.Fatalf("attach with wildcard+catchall and a correct CNAME = %d %s, want 200", code, out)
921 }
922 c := claimOf(t, fx, host)
923 if !c.Wildcard || !c.Catchall {
924 t.Fatalf("claim = %+v, want both wildcard and catchall true", *c)
925 }
926 })
927
928 t.Run("non-owner token gets the byte-identical 404", func(t *testing.T) {
929 fx, _ := instanceFixture(t, "code-dom-wc-nonowner")
930 fx.srv.delegationZone = zone
931 now := time.Now()
932 if _, err := fx.st.CreateAppIn(OpsAccountID, "other", "alice@keymail.dev", now); err != nil {
933 t.Fatal(err)
934 }
935 foreign, err := fx.st.NewAppToken(OpsAccountID, "other", "alice@keymail.dev", "signup", now)
936 if err != nil {
937 t.Fatal(err)
938 }
939 want := canonicalNotFound(t)
940 code, h, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), foreign,
941 `{"host":"`+host+`","route":"go.tito.io","wildcard":true}`)
942 assertNotFound(t, "foreign token attach with wildcard", want, code, h, out)
943 claimAbsent(t, fx, host)
944 })
945
946 // The zone-unset case doubles as the Task 1 review's authority-hole
947 // regression: the body smuggles BOTH wildcard and catchall alongside
948 // host/route, in the very attach call that creates the claim. If the
949 // handler ever passed those fields straight into AttachClaim's create
950 // path (which marshals its Claim argument verbatim), they would land
951 // even though the capability step below refuses. They must not: the
952 // only writer of Wildcard/Catchall is SetClaimCapabilities, called AFTER
953 // the create, and gated on the zone.
954 t.Run("zone unset refuses, and smuggled capability fields never reach the claim", func(t *testing.T) {
955 fx, member := instanceFixture(t, "code-dom-wc-nozone")
956 // fx.srv.delegationZone is left at its zero value "" — no
957 // CARLOS_ACME_DELEGATION_ZONE configured for this fixture.
958 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
959 `{"host":"`+host+`","route":"go.tito.io","wildcard":true,"catchall":true}`)
960 if code != http.StatusUnprocessableEntity {
961 t.Fatalf("attach with wildcard and no zone = %d %s, want 422", code, out)
962 }
963 if !strings.Contains(out, "no delegation zone") {
964 t.Fatalf("422 body = %s, want it to mention no delegation zone", out)
965 }
966 if c := claimOf(t, fx, host); c.Wildcard || c.Catchall {
967 t.Fatalf("claim after a refused capability request = %+v, want both false — the smuggled fields must not reach AttachClaim's create path", *c)
968 }
969 })
970}
971
972// TestAttachWildcardDisableCascadesCatchallOff: turning wildcard off never
973// requires DNS (the CNAME gate is skipped entirely — proven here by breaking
974// the stub before the disable call), and catchall — which cannot legally
975// outlive wildcard — cascades off in the SAME call rather than making the
976// caller send catchall=false too, or the door routing itself into
977// ErrCatchallNeedsWildcard on its own converge.
978func TestAttachWildcardDisableCascadesCatchallOff(t *testing.T) {
979 const zone = "acme.oncarlos.com"
980 const host = "wild.example.com"
981 fx, member := instanceFixture(t, "code-dom-wc-disable")
982 fx.srv.delegationZone = zone
983 wantCNAME := "_acme-challenge." + host + "." + zone + "."
984 fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) {
985 if name == "_acme-challenge."+host {
986 return wantCNAME, nil
987 }
988 return "", errors.New("not found")
989 }
990 attachDomain(t, fx, member, host, "go.tito.io")
991 if changed, err := domains.SetClaimCapabilities(context.Background(), fx.rel, host, "bab", "titogo", true, true); err != nil || !changed {
992 t.Fatalf("seed SetClaimCapabilities(wildcard,catchall) = changed=%v err=%v", changed, err)
993 }
994 // Break the stub: a disable call that still consulted it would now fail
995 // the CNAME check and 422 — proving the disable path skips it entirely.
996 fx.srv.lookupCNAME = func(context.Context, string) (string, error) { return "", errors.New("not found") }
997 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
998 `{"host":"`+host+`","route":"go.tito.io","wildcard":false}`)
999 if code != http.StatusOK {
1000 t.Fatalf("disabling wildcard = %d %s, want 200 (no CNAME check on the way off)", code, out)
1001 }
1002 c := claimOf(t, fx, host)
1003 if c.Wildcard || c.Catchall {
1004 t.Fatalf("claim after disabling wildcard = %+v, want both false (catchall cascades off)", *c)
1005 }
1006}
1007
1008// TestDomainsListDelegationStamp: a claim with Wildcard stamps
1009// delegation_status from the same CNAME check the attach gate uses —
1010// "delegated" or "missing", live-read on every list call (not cached from
1011// whenever wildcard was enabled). A claim without Wildcard never carries the
1012// field at all.
1013func TestDomainsListDelegationStamp(t *testing.T) {
1014 const zone = "acme.oncarlos.com"
1015 fx, member := instanceFixture(t, "code-dom-list-deleg")
1016 fx.srv.delegationZone = zone
1017 const wildHost = "wild.example.com"
1018 const plainHost = "plain.example.com"
1019 wantCNAME := "_acme-challenge." + wildHost + "." + zone + "."
1020 fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) {
1021 if name == "_acme-challenge."+wildHost {
1022 return wantCNAME, nil
1023 }
1024 return "", errors.New("not found")
1025 }
1026 attachDomain(t, fx, member, wildHost, "go.tito.io")
1027 if changed, err := domains.SetClaimCapabilities(context.Background(), fx.rel, wildHost, "bab", "titogo", true, false); err != nil || !changed {
1028 t.Fatalf("seed SetClaimCapabilities(%s, wildcard) = changed=%v err=%v", wildHost, changed, err)
1029 }
1030 attachDomain(t, fx, member, plainHost, "go.tito.io")
1031
1032 list := func(t *testing.T) domainListResp {
1033 t.Helper()
1034 code, _, out := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "")
1035 if code != http.StatusOK {
1036 t.Fatalf("list = %d %s, want 200", code, out)
1037 }
1038 var got domainListResp
1039 if err := json.Unmarshal([]byte(out), &got); err != nil {
1040 t.Fatalf("list body %s: %v", out, err)
1041 }
1042 return got
1043 }
1044 byHost := func(got domainListResp) map[string]struct {
1045 Wildcard bool
1046 Catchall bool
1047 DelegationStatus string
1048 } {
1049 m := map[string]struct {
1050 Wildcard bool
1051 Catchall bool
1052 DelegationStatus string
1053 }{}
1054 for _, d := range got.Domains {
1055 m[d.Host] = struct {
1056 Wildcard bool
1057 Catchall bool
1058 DelegationStatus string
1059 }{d.Wildcard, d.Catchall, d.DelegationStatus}
1060 }
1061 return m
1062 }
1063
1064 m := byHost(list(t))
1065 if row := m[wildHost]; !row.Wildcard || row.DelegationStatus != "delegated" {
1066 t.Fatalf("%s row = %+v, want wildcard true and delegated", wildHost, row)
1067 }
1068 if row := m[plainHost]; row.Wildcard || row.DelegationStatus != "" {
1069 t.Fatalf("%s row = %+v, want wildcard false and delegation_status absent", plainHost, row)
1070 }
1071
1072 // Break the CNAME stub: the same claim now reads "missing" — the stamp is
1073 // a live read, not something cached from when wildcard was enabled.
1074 fx.srv.lookupCNAME = func(context.Context, string) (string, error) { return "", errors.New("not found") }
1075 m = byHost(list(t))
1076 if row := m[wildHost]; !row.Wildcard || row.DelegationStatus != "missing" {
1077 t.Fatalf("%s row after breaking the CNAME = %+v, want wildcard true and missing", wildHost, row)
1078 }
1079}
1080
1081// TestDomainsListDelegationStampCapsLookups: IMPORTANT-2 (review) bounds the
1082// list door's live CNAME work at domainListCNAMELookupCap per call. Nine
1083// wildcard-enabled claims, host-sorted "h0".."h8": only the lexicographically
1084// first 8 get a delegation_status stamp (and only 8 lookupCNAME calls are
1085// made at all); the 9th lists with the field simply omitted, same as an
1086// unset zone or wildcard off — never an error.
1087func TestDomainsListDelegationStampCapsLookups(t *testing.T) {
1088 const zone = "acme.oncarlos.com"
1089 fx, member := instanceFixture(t, "code-dom-list-cap")
1090 fx.srv.delegationZone = zone
1091 var calls int
1092 fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) {
1093 calls++
1094 host := strings.TrimPrefix(name, "_acme-challenge.")
1095 return "_acme-challenge." + host + "." + zone + ".", nil
1096 }
1097 hosts := make([]string, 9)
1098 for i := range hosts {
1099 h := fmt.Sprintf("h%d.example.com", i)
1100 hosts[i] = h
1101 attachDomain(t, fx, member, h, "go.tito.io")
1102 if changed, err := domains.SetClaimCapabilities(context.Background(), fx.rel, h, "bab", "titogo", true, false); err != nil || !changed {
1103 t.Fatalf("seed SetClaimCapabilities(%s, wildcard) = changed=%v err=%v", h, changed, err)
1104 }
1105 }
1106 sort.Strings(hosts)
1107
1108 code, _, out := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "")
1109 if code != http.StatusOK {
1110 t.Fatalf("list = %d %s, want 200", code, out)
1111 }
1112 var got domainListResp
1113 if err := json.Unmarshal([]byte(out), &got); err != nil {
1114 t.Fatalf("list body %s: %v", out, err)
1115 }
1116 if len(got.Domains) != 9 {
1117 t.Fatalf("list = %d rows, want 9", len(got.Domains))
1118 }
1119 byHost := map[string]string{}
1120 for _, d := range got.Domains {
1121 byHost[d.Host] = d.DelegationStatus
1122 }
1123 for i, h := range hosts {
1124 if i < 8 {
1125 if byHost[h] != "delegated" {
1126 t.Errorf("host %d (%s) delegation_status = %q, want delegated (within the cap)", i, h, byHost[h])
1127 }
1128 } else {
1129 if byHost[h] != "" {
1130 t.Errorf("host %d (%s) delegation_status = %q, want omitted (beyond the cap)", i, h, byHost[h])
1131 }
1132 }
1133 }
1134 if calls != 8 {
1135 t.Fatalf("lookupCNAME was called %d times, want exactly 8 (the cap)", calls)
1136 }
1137}
1138
1139// ---- zone validation at construction (Task 5 review, IMPORTANT-4) ---------
1140
1141// TestNormalizeDelegationZone: CARLOS_ACME_DELEGATION_ZONE is lowercased,
1142// trimmed, and shape-checked the same way the edge validates the identical
1143// variable (internal/edge/wildcard.go) — an invalid value degrades to "",
1144// never a panic or a value handed to lookupCNAME verbatim.
1145func TestNormalizeDelegationZone(t *testing.T) {
1146 for _, tc := range []struct{ raw, want string }{
1147 {"", ""},
1148 {" ", ""},
1149 {"ACME.OnCarlos.Com", "acme.oncarlos.com"},
1150 {" acme.oncarlos.com ", "acme.oncarlos.com"},
1151 {"*.bad zone", ""},
1152 {"-leading-hyphen.example.com", ""},
1153 {"has a space.example.com", ""},
1154 } {
1155 if got := normalizeDelegationZone(tc.raw); got != tc.want {
1156 t.Errorf("normalizeDelegationZone(%q) = %q, want %q", tc.raw, got, tc.want)
1157 }
1158 }
1159}
1160
1161// zoneFixtureWithDelegation builds a full console fixture the same shape
1162// instanceFixture does, but with Config.DelegationZone set AT CONSTRUCTION —
1163// the only way to exercise NewServer's own zone validation (IMPORTANT-4):
1164// every other test in this file stubs delegationZone directly on an
1165// already-constructed Server, which bypasses normalizeDelegationZone
1166// entirely.
1167func zoneFixtureWithDelegation(t *testing.T, zone, code string) (*consoleFixture, string) {
1168 t.Helper()
1169 regPath, relStore, priv, pub := buildFixtures(t)
1170 st, err := Open(filepath.Join(t.TempDir(), "console.db"))
1171 if err != nil {
1172 t.Fatal(err)
1173 }
1174 t.Cleanup(func() { st.Close() })
1175 fk := newFakeKeymail(t, "paul@keymail.dev")
1176 var srv *Server
1177 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1178 srv.ServeHTTP(w, r)
1179 }))
1180 t.Cleanup(ts.Close)
1181 srv, err = NewServer(Config{
1182 Origin: ts.URL, Keymail: fk.srv.URL, Registry: regPath, Releases: relStore,
1183 ReleasePub: pub, Key: priv, AppScheme: "http", DelegationZone: zone,
1184 }, st)
1185 if err != nil {
1186 t.Fatal(err)
1187 }
1188 srv.instances.timeout = 100 * time.Millisecond
1189 if err := st.AddMember(OpsAccountID, "paul@keymail.dev", RoleOwner, time.Now()); err != nil {
1190 t.Fatal(err)
1191 }
1192 if _, err := st.CreateAppIn(OpsAccountID, "kass", "paul@keymail.dev", time.Now()); err != nil {
1193 t.Fatal(err)
1194 }
1195 fx := &consoleFixture{ts: ts, srv: srv, st: st, fk: fk, rel: relStore, regPath: regPath,
1196 priv: priv, pub: pub, sqid: release.OpsAccount}
1197 now := time.Now()
1198 if _, err := st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
1199 t.Fatal(err)
1200 }
1201 if err := st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
1202 t.Fatal(err)
1203 }
1204 return fx, cliToken(t, fx, "alice@keymail.dev", code)
1205}
1206
1207// TestDelegationZoneInvalidAtConstruction: a garbage
1208// CARLOS_ACME_DELEGATION_ZONE degrades to delegation-off at Server
1209// construction (IMPORTANT-4) — a wildcard-enable request against it answers
1210// the ordinary "no delegation zone configured" 422, the exact same refusal
1211// an unset zone gets, never a distinguishable error and never a lookupCNAME
1212// call built from an invalid zone.
1213func TestDelegationZoneInvalidAtConstruction(t *testing.T) {
1214 fx, member := zoneFixtureWithDelegation(t, "*.bad zone", "code-dom-wc-badzone")
1215 fx.srv.lookupCNAME = func(context.Context, string) (string, error) {
1216 t.Fatal("lookupCNAME must not be called when the configured zone is invalid")
1217 return "", nil
1218 }
1219 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
1220 `{"host":"woodstar.app","route":"go.tito.io","wildcard":true}`)
1221 if code != http.StatusUnprocessableEntity {
1222 t.Fatalf("attach with wildcard under an invalid configured zone = %d %s, want 422", code, out)
1223 }
1224 if !strings.Contains(out, "no delegation zone") {
1225 t.Fatalf("422 body = %s, want it to mention no delegation zone", out)
1226 }
1227}
1228
1229// ---- wildcard exemption from the write/instance budgets (Task 5, spec §4a)
1230
1231// TestWildcardEnableIsNotRateLimited: a Pages hub with wildcard DNS makes no
1232// per-repo hostname call at all — one claim on the hub host with both flags,
1233// every repo's subdomain serves off the catchall. So the domains doors are
1234// EXEMPT from the write/instance WORKLOAD budgets rather than uplifted.
1235//
1236// attempts is deliberately ABOVE both cliInstanceBurst (the instance workload
1237// budget this door must be exempt from) and svcWriteBurst (the write budget
1238// it must equally be exempt from) and BELOW domainsCeilingBurst (the
1239// convergence-rate ceiling that still applies, see
1240// TestDomainsCeilingBoundsConvergenceRate below): a pass here means BOTH
1241// workload budgets are genuinely gone, not that the loop merely stayed under
1242// one of them. domainsCeilingBurst is kept deliberately clear of
1243// svcWriteBurst (see its comment, authcli.go) so that a volume satisfying
1244// this inequality can exist at all — when the two constants were equal, no
1245// attempts count could sit strictly between them, and a regression that put
1246// the write budget back onto this door would have refused at the same
1247// threshold this ceiling does, so this test would have kept passing anyway.
1248// Derived from all three constants, not a literal, so the claim cannot
1249// quietly stop being true if any of them moves — the assertion just below
1250// catches that instead of the test silently starting to prove nothing
1251// (Task 5 review, round 3; sharpened in the fix wave).
1252func TestWildcardEnableIsNotRateLimited(t *testing.T) {
1253 const zone = "acme.example"
1254 const floor = max(cliInstanceBurst, svcWriteBurst)
1255 const attempts = (floor + domainsCeilingBurst) / 2
1256 if !(cliInstanceBurst < attempts && svcWriteBurst < attempts && attempts < domainsCeilingBurst) {
1257 t.Fatalf("attempts (%d) must sit strictly above cliInstanceBurst (%d) and svcWriteBurst (%d) and strictly below domainsCeilingBurst (%d) — as written, this test would no longer prove the exemption it is named for",
1258 attempts, cliInstanceBurst, svcWriteBurst, domainsCeilingBurst)
1259 }
1260 fx, _ := instanceFixture(t, "code-wc-exempt")
1261 svc := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Publish)
1262 fx.srv.delegationZone = zone
1263 fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) {
1264 return name + "." + zone, nil
1265 }
1266 for i := 0; i < attempts; i++ {
1267 host := fmt.Sprintf("r%d.pages.example", i)
1268 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), svc,
1269 `{"host":"`+host+`","route":"hub.pages.example","wildcard":true,"catchall":true}`)
1270 if code == http.StatusTooManyRequests {
1271 t.Fatalf("attach %d = 429: %s", i, out)
1272 }
1273 if code != http.StatusOK {
1274 t.Fatalf("attach %d = %d: %s", i, code, out)
1275 }
1276 }
1277}
1278
1279// TestDomainsCeilingBoundsConvergenceRate is C34's correction: the
1280// exemption above establishes that the legitimate wildcard workload does
1281// not NEED a budget, not that no budget is safe. Every effective attach
1282// audits and bumps control/serial, which every box polls at 2s and reacts
1283// to with a full pass, so domainsGate keeps a convergence-rate ceiling
1284// (domainsCeilingBurst, keyed on the principal) even though it dropped the
1285// write/instance budgets. Plain, non-wildcard attaches are enough to show
1286// it — the ceiling applies to the door, not to the wildcard flags.
1287func TestDomainsCeilingBoundsConvergenceRate(t *testing.T) {
1288 fx, _ := instanceFixture(t, "code-wc-ceiling")
1289 svc := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Publish)
1290 var saw429 bool
1291 for i := 0; i < domainsCeilingBurst+5; i++ {
1292 host := fmt.Sprintf("c%d.pages.example", i)
1293 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), svc,
1294 `{"host":"`+host+`","route":"hub.pages.example"}`)
1295 if code == http.StatusTooManyRequests {
1296 saw429 = true
1297 break
1298 }
1299 if code != http.StatusOK {
1300 t.Fatalf("attach %d = %d: %s", i, code, out)
1301 }
1302 }
1303 if !saw429 {
1304 t.Error("unbounded attaches from one credential; want a 429 after domainsCeilingBurst")
1305 }
1306}
1307
1308// TestFailedWildcardEnableIsBounded is the hazard the exemption has to
1309// survive: the CNAME check is a live, sequential DNS round trip on the
1310// request path, and a caller whose CNAME is not there yet can retry
1311// forever. A SUCCEEDING enable is once-per-domain and exempt (the test
1312// above); a FAILING one is bounded at wildFailBurst per principal per
1313// minute.
1314func TestFailedWildcardEnableIsBounded(t *testing.T) {
1315 const zone = "acme.example"
1316 fx, _ := instanceFixture(t, "code-wc-bounded")
1317 svc := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Publish)
1318 fx.srv.delegationZone = zone
1319 fx.srv.lookupCNAME = func(context.Context, string) (string, error) {
1320 return "", errors.New("not found") // no _acme-challenge CNAME anywhere
1321 }
1322 var saw429 bool
1323 for i := 0; i < wildFailBurst+5; i++ {
1324 code, _, _ := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), svc,
1325 `{"host":"retry.pages.example","route":"hub.pages.example","wildcard":true}`)
1326 if code == http.StatusTooManyRequests {
1327 saw429 = true
1328 break
1329 }
1330 if code != http.StatusUnprocessableEntity {
1331 t.Fatalf("failing attach %d = %d, want 422 (until the bound trips)", i, code)
1332 }
1333 }
1334 if !saw429 {
1335 t.Error("unbounded failing wildcard enables; want a 429 after wildFailBurst")
1336 }
1337}
1338
1339// TestIdempotentReattachTouchesNeitherBudget: titogo's router re-attaches
1340// every hostname with its capability flags on every reload pass. `enabling`
1341// is a false->true transition, so a re-send of wildcard:true on an
1342// already-wildcard claim skips the DNS lookup entirely — it must skip the
1343// failure bound with it, even once the resolver goes dark.
1344func TestIdempotentReattachTouchesNeitherBudget(t *testing.T) {
1345 const zone = "acme.example"
1346 fx, _ := instanceFixture(t, "code-wc-idempotent")
1347 svc := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Instance)
1348 fx.srv.delegationZone = zone
1349 const host = "one.pages.example"
1350 fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) {
1351 return name + "." + zone, nil
1352 }
1353 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), svc,
1354 `{"host":"`+host+`","route":"hub.pages.example","wildcard":true,"catchall":true}`)
1355 if code != http.StatusOK {
1356 t.Fatalf("first attach = %d: %s", code, out)
1357 }
1358 fx.srv.lookupCNAME = func(context.Context, string) (string, error) {
1359 return "", errors.New("resolver is dark") // a re-attach must not care
1360 }
1361 for i := 0; i < wildFailBurst*2; i++ {
1362 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), svc,
1363 `{"host":"`+host+`","route":"hub.pages.example","wildcard":true,"catchall":true}`)
1364 if code != http.StatusOK {
1365 t.Fatalf("re-attach %d = %d: %s", i, code, out)
1366 }
1367 }
1368}
1369
1370// TestLegacyAppTokensHaveSeparateWildFailBudgets is C32's fix, made
1371// concrete: titogo's routers each hold their OWN app token (kind='app',
1372// Name==""), often bound to different apps in the same account. Before the
1373// fix, principalKey collapsed every one of them onto "service:<id>/" —
1374// whichever app it was bound to — so one router's mistyped CNAME could 429
1375// every other router's wildcard enable in the account. auditActor already
1376// keys this exact row on App for the same reason; principalKey must agree.
1377func TestLegacyAppTokensHaveSeparateWildFailBudgets(t *testing.T) {
1378 const zone = "acme.example"
1379 fx, _ := instanceFixture(t, "code-wc-legacy-sep")
1380 now := time.Now()
1381 if _, err := fx.st.CreateAppIn(OpsAccountID, "widgets", "paul@keymail.dev", now); err != nil {
1382 t.Fatal(err)
1383 }
1384 tokTitogo, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router-titogo", now)
1385 if err != nil {
1386 t.Fatalf("NewAppToken(titogo): %v", err)
1387 }
1388 tokWidgets, err := fx.st.NewAppToken(OpsAccountID, "widgets", "paul@keymail.dev", "router-widgets", now)
1389 if err != nil {
1390 t.Fatalf("NewAppToken(widgets): %v", err)
1391 }
1392 fx.srv.delegationZone = zone
1393 fx.srv.lookupCNAME = func(context.Context, string) (string, error) {
1394 return "", errors.New("not found") // no _acme-challenge CNAME anywhere
1395 }
1396 // Exhaust titogo's app token's wildFail budget.
1397 var saw429 bool
1398 for i := 0; i < wildFailBurst+5; i++ {
1399 code, _, _ := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), tokTitogo,
1400 `{"host":"retry.pages.example","route":"hub.pages.example","wildcard":true}`)
1401 if code == http.StatusTooManyRequests {
1402 saw429 = true
1403 break
1404 }
1405 if code != http.StatusUnprocessableEntity {
1406 t.Fatalf("titogo failing attach %d = %d, want 422 (until the bound trips)", i, code)
1407 }
1408 }
1409 if !saw429 {
1410 t.Fatal("titogo's app token never tripped wildFail — the setup is broken, not the fix")
1411 }
1412 // widgets' app token — a DIFFERENT app, same account — must be
1413 // untouched: a single failure answers 422, never 429.
1414 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "widgets"), tokWidgets,
1415 `{"host":"retry2.pages.example","route":"hub.pages.example","wildcard":true}`)
1416 if code != http.StatusUnprocessableEntity {
1417 t.Fatalf("widgets' app token after titogo's exhausted its budget = %d: %s, want 422 (separate budget)", code, out)
1418 }
1419}
1420
1421// TestAPIDomainAttachRefusesADeploymentRouteNeutrally: defence in depth for
1422// the fleet-wide route collision. An operator-minted route carries no
1423// instance record, so this door has never been able to see it and
1424// deliberately does not verify that a route exists — which left a hostname
1425// another box already SERVES free for anyone to claim. Now that aliases
1426// publish fleet-wide, such a claim captures that traffic on every remote
1427// edge, so the door refuses it.
1428//
1429// The refusal must be indistinguishable from the "someone else holds it"
1430// 409 in TWO ways, and this test asserts both:
1431//
1432// 1. SAME BYTES. Status and body are compared against the pre-existing
1433// refusal itself, not against a literal, so the two cannot drift apart.
1434//
1435// 2. SAME REACHABILITY. Byte-identical bodies are worthless if the new
1436// check runs EARLIER than the old one, because a prober can then
1437// deliberately fail a later gate and read the two apart by which
1438// refusal comes back first (Task 5 review). So the probes below use a
1439// BAD route and an ABSENT route — request shapes that must be refused
1440// by resolveAliasRoute before the hostname is ever considered — and
1441// assert a deployment-route host and a never-heard-of host answer
1442// IDENTICALLY. With the guard ordered above resolveAliasRoute these
1443// read 409 vs 400 and the deployment's whole non-alias route set can be
1444// enumerated one host at a time, side-effect-free, inside the ceiling.
1445//
1446// A test that only probes with a VALID route (as the first version of this
1447// one did) cannot see failure mode 2 at all.
1448func TestAPIDomainAttachRefusesADeploymentRouteNeutrally(t *testing.T) {
1449 ctx := context.Background()
1450 fx, member := instanceFixture(t, "code-dom-deproute")
1451
1452 // go.tito.io is a real, NON-ALIAS route published by a live box called
1453 // "annie". Both halves are required: DeploymentRoutes drops any edges
1454 // file whose box has no fresh status snapshot.
1455 if _, err := fx.rel.Put(ctx, edgesfile.Key("annie"),
1456 []byte(`{"v":1,"box":"annie","eip":"198.51.100.9","hosts":{"go.tito.io":{"role":"home"}}}`), ""); err != nil {
1457 t.Fatal(err)
1458 }
1459 if err := status.WriteBox(ctx, fx.rel, status.Box{Label: "annie", GeneratedAt: time.Now()}); err != nil {
1460 t.Fatal(err)
1461 }
1462 // A hostname held by a different account: the pre-existing refusal, and
1463 // the yardstick the new one is measured against.
1464 if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{
1465 Host: "claimed-by-someone-else.com", Account: "cdef", App: "widgets",
1466 Route: "widgets.example.net", CreatedAt: time.Now().UTC().Format(time.RFC3339),
1467 }); err != nil {
1468 t.Fatal(err)
1469 }
1470
1471 attach := func(body string) (int, string) {
1472 t.Helper()
1473 code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, body)
1474 return code, strings.TrimSpace(out)
1475 }
1476
1477 // (1) Same bytes: with a perfectly valid route, both refused hostnames
1478 // answer the identical neutral 409.
1479 takenCode, takenBody := attach(attachBody("go.tito.io", "two.go.tito.io"))
1480 heldCode, heldBody := attach(attachBody("claimed-by-someone-else.com", "two.go.tito.io"))
1481 if takenCode != http.StatusConflict {
1482 t.Fatalf("attach of another box's route = %d %s, want 409", takenCode, takenBody)
1483 }
1484 if takenCode != heldCode || takenBody != heldBody {
1485 t.Fatalf("refusal %d %q differs from the existing unavailable %d %q — that is an existence oracle",
1486 takenCode, takenBody, heldCode, heldBody)
1487 }
1488 claimAbsent(t, fx, "go.tito.io")
1489
1490 // (2) Same reachability. "never-heard-of.example" is not a deployment
1491 // route, is claimed by nobody, and is not denied — the ONLY thing that
1492 // differs between the two probes in each pair is whether a live box
1493 // serves the hostname. That difference must not be observable.
1494 for _, probe := range []struct {
1495 what string
1496 body func(host string) string
1497 }{
1498 // A malformed route: resolveAliasRoute's 400 must come first.
1499 {"a malformed route", func(host string) string { return attachBody(host, "!!") }},
1500 // No route at all, from an app with zero instance records: the
1501 // "specify -route" 400 must come first. Needs no malformed input at
1502 // all, which is what makes it the cheap version of this probe.
1503 {"an absent route", func(host string) string { return attachBody(host, "") }},
1504 // The kind=static branch, which takes no route and so has none of
1505 // the gates above — its later gate is attachChannel, inside the
1506 // shared attachDomain core. Round 1 left the guard at the top of
1507 // this branch on the reasoning that there was nothing earlier to
1508 // order against; there was nothing earlier, but there was something
1509 // LATER, and that is just as readable. Round 2 moved the guard down
1510 // into the core, immediately before each AttachClaim.
1511 {"a malformed channel on the static branch", func(host string) string {
1512 b, err := json.Marshal(map[string]string{"host": host, "kind": "static", "channel": "!!"})
1513 if err != nil {
1514 t.Fatal(err)
1515 }
1516 return string(b)
1517 }},
1518 } {
1519 routeCode, routeBody := attach(probe.body("go.tito.io"))
1520 unknownCode, unknownBody := attach(probe.body("never-heard-of.example"))
1521 if routeCode != unknownCode || routeBody != unknownBody {
1522 t.Errorf("with %s, a deployment-route host answered %d %q but an unknown host answered %d %q — "+
1523 "the deployment-route check is ordered ABOVE the gate that should refuse first, "+
1524 "so this door enumerates every non-alias route in the fleet",
1525 probe.what, routeCode, routeBody, unknownCode, unknownBody)
1526 }
1527 if routeCode == http.StatusConflict {
1528 t.Errorf("with %s, the request was refused as a conflict (%d %q) — a request that "+
1529 "cannot name a route must never get as far as deciding whether the hostname is free",
1530 probe.what, routeCode, routeBody)
1531 }
1532 }
1533
1534 // (3) The kind=static branch actually REFUSES, not merely refuses in the
1535 // right order. The ordering probes above cannot see this: with a
1536 // malformed channel, attachChannel refuses BOTH hosts identically and
1537 // the pair agrees whether or not the guard behind it exists at all. So
1538 // probe once more with a VALID channel, where nothing stands between the
1539 // guard and the claim write, and compare against the held hostname on
1540 // the same branch.
1541 staticBody := func(host string) string {
1542 b, err := json.Marshal(map[string]string{"host": host, "kind": "static", "channel": "stable"})
1543 if err != nil {
1544 t.Fatal(err)
1545 }
1546 return string(b)
1547 }
1548 staticRouteCode, staticRouteBody := attach(staticBody("go.tito.io"))
1549 staticHeldCode, staticHeldBody := attach(staticBody("claimed-by-someone-else.com"))
1550 if staticRouteCode != http.StatusConflict {
1551 t.Errorf("kind=static attach of another box's route = %d %q, want 409 — the first-route "+
1552 "branch reaches AttachClaim with no deployment-route guard in front of it",
1553 staticRouteCode, staticRouteBody)
1554 }
1555 if staticRouteCode != staticHeldCode || staticRouteBody != staticHeldBody {
1556 t.Errorf("kind=static refusal %d %q differs from the held-hostname refusal %d %q — that is an existence oracle",
1557 staticRouteCode, staticRouteBody, staticHeldCode, staticHeldBody)
1558 }
1559
1560 // Nothing was written by any probe.
1561 claimAbsent(t, fx, "go.tito.io")
1562 claimAbsent(t, fx, "never-heard-of.example")
1563}
1564
1565// A hostname this very app ALREADY holds the claim on stays attachable. This
1566// is not a softening of the rule above, it is what makes it survive a
1567// first-route attach: such an attach materializes the host as a REAL route
1568// on the claimant's own box, so from the second call onward the fold reports
1569// the caller's own route back at them. titogo's router re-attaches every
1570// hostname on each reload pass, so without this the steady state would be a
1571// permanent 409 on hostnames this deployment itself granted.
1572func TestAPIDomainAttachStillConvergesTheCallersOwnDeploymentRoute(t *testing.T) {
1573 ctx := context.Background()
1574 fx, member := instanceFixture(t, "code-dom-ownroute")
1575 if _, err := fx.rel.Put(ctx, edgesfile.Key("annie"),
1576 []byte(`{"v":1,"box":"annie","eip":"198.51.100.9","hosts":{"mine.example.com":{"role":"home"}}}`), ""); err != nil {
1577 t.Fatal(err)
1578 }
1579 if err := status.WriteBox(ctx, fx.rel, status.Box{Label: "annie", GeneratedAt: time.Now()}); err != nil {
1580 t.Fatal(err)
1581 }
1582 if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{
1583 Host: "mine.example.com", Account: "bab", App: "titogo",
1584 CreatedAt: time.Now().UTC().Format(time.RFC3339),
1585 }); err != nil {
1586 t.Fatal(err)
1587 }
1588 if got := attachDomain(t, fx, member, "mine.example.com", "go.tito.io"); got.Created {
1589 t.Fatalf("re-attach of the caller's own hostname = %+v, want 200 created=false", got)
1590 }
1591}
1592
1593// readingsTestStore is a bare release store for aliasReadings tests that
1594// don't need a full consoleFixture — same DirStore-over-TempDir pattern
1595// status_test.go's teststore uses.
1596func readingsTestStore(t *testing.T) release.Store {
1597 t.Helper()
1598 return release.NewDirStore(t.TempDir())
1599}
1600
1601// writeBoxWithAlias publishes one box whose Metrics.Aliases carries a single
1602// reading, all under the fixed hostname "shop.customer.com" — the tests
1603// using this helper are about the FOLD across boxes (freshest stamp wins),
1604// not about hostname routing, so every box in a given test contends for the
1605// same key on purpose.
1606func writeBoxWithAlias(t *testing.T, s release.Store, label string, now time.Time, reading status.AliasReading) {
1607 t.Helper()
1608 if err := status.WriteBox(context.Background(), s, status.Box{
1609 Label: label, GeneratedAt: now,
1610 Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{
1611 "shop.customer.com": reading,
1612 }},
1613 }); err != nil {
1614 t.Fatal(err)
1615 }
1616}
1617
1618// Two boxes can both carry an alias (an everywhere-class primary). The fold
1619// must take the FRESHEST stamp, not whichever box the map happened to yield
1620// last — otherwise a frozen reading deterministically overwrites a live one.
1621//
1622// DirStore.List returns keys sorted, and WriteBox keys off the box label, so
1623// LoadBoxes iterates boxes in label order regardless of write order. A test
1624// with only one label ordering can't tell a genuine freshness comparison
1625// apart from a last-write-wins bug that happens to agree with it for that
1626// ordering — so this asserts BOTH directions: the fresher box sorting
1627// alphabetically first (where a last-write-wins fold would wrongly keep the
1628// stale, later-sorted box's reading) and sorting last (where naive
1629// last-write-wins would coincidentally get it right too). Only a real
1630// DNSCheckedAt comparison passes both.
1631func TestAliasReadingsPrefersTheFreshestStamp(t *testing.T) {
1632 t.Run("fresher box sorts first", func(t *testing.T) {
1633 s := readingsTestStore(t)
1634 now := time.Unix(1756300060, 0)
1635 writeBoxWithAlias(t, s, "annie", now, status.AliasReading{
1636 Route: "app.example.com", DNSStatus: "pointing", DNSCheckedAt: 1756300000})
1637 writeBoxWithAlias(t, s, "betty", now, status.AliasReading{
1638 Route: "app.example.com", DNSStatus: "elsewhere", DNSCheckedAt: 1756200000})
1639
1640 got := aliasReadings(context.Background(), s, now)
1641 if got["shop.customer.com"].DNSStatus != "pointing" {
1642 t.Fatalf("reading = %+v, want the freshest box's stamp (annie, which sorts first)", got["shop.customer.com"])
1643 }
1644 })
1645
1646 t.Run("fresher box sorts last", func(t *testing.T) {
1647 s := readingsTestStore(t)
1648 now := time.Unix(1756300060, 0)
1649 writeBoxWithAlias(t, s, "annie", now, status.AliasReading{
1650 Route: "app.example.com", DNSStatus: "elsewhere", DNSCheckedAt: 1756200000})
1651 writeBoxWithAlias(t, s, "betty", now, status.AliasReading{
1652 Route: "app.example.com", DNSStatus: "pointing", DNSCheckedAt: 1756300000})
1653
1654 got := aliasReadings(context.Background(), s, now)
1655 if got["shop.customer.com"].DNSStatus != "pointing" {
1656 t.Fatalf("reading = %+v, want the freshest box's stamp (betty, which sorts last)", got["shop.customer.com"])
1657 }
1658 })
1659}
1660
1661// domainGetURL is the per-host read's URL (#299 §2e).
1662func domainGetURL(fx *consoleFixture, acct, app, host string) string {
1663 return domainsURL(fx, acct, app) + "/" + host
1664}
1665
1666// The per-host door answers the SAME verdict as the list door for the same
1667// hostname. It exists to be cheap, not to be different — a consumer that
1668// refreshes one host and compares it against a listed row must not see a
1669// mirrored status flap for reasons that have nothing to do with the domain.
1670func TestAPIDomainGetAgreesWithTheListRow(t *testing.T) {
1671 fx, member := instanceFixture(t, "code-dom-get")
1672 ctx := context.Background()
1673 now := time.Now()
1674 attachDomain(t, fx, member, "live.example.com", "go.tito.io")
1675 if err := status.WriteBox(ctx, fx.rel, status.Box{
1676 GeneratedAt: now, Label: "ada-reporting", Region: "eu-west-1",
1677 Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{
1678 "live.example.com": {Route: "go.tito.io", DNSStatus: "pointing",
1679 CertExpiresAt: now.Add(24 * time.Hour).Unix(), DNSCheckedAt: 1786000123},
1680 }},
1681 }); err != nil {
1682 t.Fatal(err)
1683 }
1684
1685 code, _, out := rawReq(t, http.MethodGet, domainGetURL(fx, "bab", "titogo", "live.example.com"), member, "")
1686 if code != http.StatusOK {
1687 t.Fatalf("get = %d %s, want 200", code, out)
1688 }
1689 var got struct {
1690 Domain struct {
1691 Host string `json:"host"`
1692 State string `json:"state"`
1693 Reason string `json:"reason"`
1694 CheckedAt int64 `json:"checked_at"`
1695 } `json:"domain"`
1696 Deployment struct {
1697 ManagedDomains bool `json:"managed_domains"`
1698 } `json:"deployment"`
1699 }
1700 if err := json.Unmarshal([]byte(out), &got); err != nil {
1701 t.Fatalf("get body %s: %v", out, err)
1702 }
1703 if got.Domain.Host != "live.example.com" || got.Domain.State != StateLive {
1704 t.Fatalf("domain = %+v, want live.example.com in state %q", got.Domain, StateLive)
1705 }
1706 // checked_at is what makes a mirrored state ageable. Without it a
1707 // consumer cannot tell a verdict confirmed a minute ago from one
1708 // confirmed before an outage began, and "live" is not monotonic.
1709 if got.Domain.CheckedAt != 1786000123 {
1710 t.Errorf("checked_at = %d, want the box's own stamp", got.Domain.CheckedAt)
1711 }
1712 // The capability block rides on this answer too, so a client refreshing
1713 // one host never has to call the list door just to learn the deployment
1714 // still manages domains.
1715 if !got.Deployment.ManagedDomains {
1716 t.Error("the per-host answer carries no capability block")
1717 }
1718
1719 // And the same host, listed, agrees.
1720 _, _, listOut := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "")
1721 var list struct {
1722 Domains []struct {
1723 Host string `json:"host"`
1724 State string `json:"state"`
1725 CheckedAt int64 `json:"checked_at"`
1726 } `json:"domains"`
1727 }
1728 if err := json.Unmarshal([]byte(listOut), &list); err != nil {
1729 t.Fatal(err)
1730 }
1731 if len(list.Domains) != 1 {
1732 t.Fatalf("list = %+v", list.Domains)
1733 }
1734 if list.Domains[0].State != got.Domain.State || list.Domains[0].CheckedAt != got.Domain.CheckedAt {
1735 t.Fatalf("list row %+v disagrees with the per-host answer %+v", list.Domains[0], got.Domain)
1736 }
1737}
1738
1739// THE tenant-isolation property. The claims keyspace is GLOBAL — the key is
1740// the cross-account uniqueness gate — so a host another account holds must be
1741// indistinguishable from a host nobody has claimed. Otherwise this door
1742// becomes an existence oracle over every hostname in the deployment, leaking
1743// exactly what AttachClaim's neutral refusal protects.
1744func TestAPIDomainGetCannotDistinguishAnotherOwnerFromUnclaimed(t *testing.T) {
1745 fx, member := instanceFixture(t, "code-dom-get-iso")
1746 ctx := context.Background()
1747 now := time.Now()
1748 if _, err := fx.st.CreateAppIn(OpsAccountID, "other", "alice@keymail.dev", now); err != nil {
1749 t.Fatal(err)
1750 }
1751 if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{
1752 Host: "taken.example.com", Account: "bab", App: "other",
1753 Route: "other.go.tito.io", CreatedAt: now.UTC().Format(time.RFC3339),
1754 }); err != nil {
1755 t.Fatal(err)
1756 }
1757
1758 takenCode, _, takenBody := rawReq(t, http.MethodGet, domainGetURL(fx, "bab", "titogo", "taken.example.com"), member, "")
1759 freeCode, _, freeBody := rawReq(t, http.MethodGet, domainGetURL(fx, "bab", "titogo", "nobody.example.com"), member, "")
1760
1761 if takenCode != http.StatusNotFound || freeCode != http.StatusNotFound {
1762 t.Fatalf("taken = %d, unclaimed = %d; both must be 404", takenCode, freeCode)
1763 }
1764 if takenBody != freeBody {
1765 t.Fatalf("a host another account holds answers %q but an unclaimed one answers %q — that difference IS the oracle", takenBody, freeBody)
1766 }
1767}
1768
1769// A {host} segment arrives percent-decoded, so a traversal attempt reaches
1770// the handler as a path with separators in it. It must be refused on SHAPE,
1771// before anything builds a bucket key from it.
1772func TestAPIDomainGetRefusesATraversalHost(t *testing.T) {
1773 fx, member := instanceFixture(t, "code-dom-get-trav")
1774 for _, bad := range []string{"..%2f..%2finstances.json", "not a host", "%2e%2e%2fclaims"} {
1775 code, _, _ := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo")+"/"+bad, member, "")
1776 if code == http.StatusOK {
1777 t.Errorf("%q answered 200", bad)
1778 }
1779 }
1780}
1781
1782// The whole of §3e, end to end and through both doors: an issuer's outcome
1783// record in the bucket is what turns a hostname that has been trying for
1784// hours from "issuing-cert" into "failed". Both doors must reach the same
1785// verdict — one reads the record by key, the other folds the prefix, and a
1786// consumer refreshing a single host and comparing it against a listed row
1787// must never see them disagree.
1788func TestAPIDomainFailedStateComesFromTheWarmRecord(t *testing.T) {
1789 fx, member := instanceFixture(t, "code-dom-warm")
1790 ctx := context.Background()
1791 now := time.Now()
1792 attachDomain(t, fx, member, "broken.example.com", "go.tito.io")
1793 if err := status.WriteBox(ctx, fx.rel, status.Box{
1794 GeneratedAt: now, Label: "ada-reporting", Region: "eu-west-1",
1795 Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{
1796 // DNS is right. No certificate. Without a record this is
1797 // "issuing-cert" and stays that way forever.
1798 "broken.example.com": {Route: "go.tito.io", DNSStatus: "pointing", DNSCheckedAt: now.Unix()},
1799 }},
1800 }); err != nil {
1801 t.Fatal(err)
1802 }
1803
1804 state := func(t *testing.T) (get, list string) {
1805 t.Helper()
1806 code, _, out := rawReq(t, http.MethodGet, domainGetURL(fx, "bab", "titogo", "broken.example.com"), member, "")
1807 if code != http.StatusOK {
1808 t.Fatalf("get = %d %s", code, out)
1809 }
1810 var one struct {
1811 Domain struct{ State string } `json:"domain"`
1812 }
1813 if err := json.Unmarshal([]byte(out), &one); err != nil {
1814 t.Fatal(err)
1815 }
1816 _, _, listOut := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "")
1817 var many struct {
1818 Domains []struct{ State string } `json:"domains"`
1819 }
1820 if err := json.Unmarshal([]byte(listOut), &many); err != nil {
1821 t.Fatal(err)
1822 }
1823 if len(many.Domains) != 1 {
1824 t.Fatalf("list = %+v", many.Domains)
1825 }
1826 return one.Domain.State, many.Domains[0].State
1827 }
1828
1829 if get, list := state(t); get != StateIssuingCert || list != StateIssuingCert {
1830 t.Fatalf("with no record: get=%q list=%q, want both %q", get, list, StateIssuingCert)
1831 }
1832
1833 writeWarm := func(t *testing.T, rec certwarm.Record) {
1834 t.Helper()
1835 body, err := rec.Encode(now)
1836 if err != nil {
1837 t.Fatal(err)
1838 }
1839 _, etag, err := fx.rel.Get(ctx, certwarm.Key(rec.Host))
1840 if err != nil && !errors.Is(err, os.ErrNotExist) {
1841 t.Fatal(err)
1842 }
1843 if _, err := fx.rel.Put(ctx, certwarm.Key(rec.Host), body, etag); err != nil {
1844 t.Fatal(err)
1845 }
1846 }
1847
1848 // One failed attempt is ordinary and must not change the story.
1849 writeWarm(t, certwarm.Record{Host: "broken.example.com", Attempts: 1,
1850 Class: certwarm.ClassOther, NextAttemptAt: now.Add(time.Hour).Unix()})
1851 if get, list := state(t); get != StateIssuingCert || list != StateIssuingCert {
1852 t.Fatalf("after one failure: get=%q list=%q, want both %q", get, list, StateIssuingCert)
1853 }
1854
1855 // At the threshold both doors say so.
1856 writeWarm(t, certwarm.Record{Host: "broken.example.com", Attempts: domainFailedAttempts,
1857 Class: certwarm.ClassOther, NextAttemptAt: now.Add(time.Hour).Unix()})
1858 if get, list := state(t); get != StateFailed || list != StateFailed {
1859 t.Fatalf("at the threshold: get=%q list=%q, want both %q", get, list, StateFailed)
1860 }
1861}
1862
1863// A record for somebody ELSE's hostname must not reach this app's answer.
1864// The cert-warm prefix is deployment-global (there is one issuer, and it
1865// writes for every domain it warms), so the join has to be by exact host or
1866// one tenant's certificate trouble shows up on another tenant's domain.
1867func TestAPIDomainWarmRecordsJoinByExactHost(t *testing.T) {
1868 fx, member := instanceFixture(t, "code-dom-warm-iso")
1869 ctx := context.Background()
1870 now := time.Now()
1871 attachDomain(t, fx, member, "mine.example.com", "go.tito.io")
1872 if err := status.WriteBox(ctx, fx.rel, status.Box{
1873 GeneratedAt: now, Label: "ada-reporting", Region: "eu-west-1",
1874 Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{
1875 "mine.example.com": {Route: "go.tito.io", DNSStatus: "pointing", DNSCheckedAt: now.Unix()},
1876 }},
1877 }); err != nil {
1878 t.Fatal(err)
1879 }
1880 body, err := certwarm.Record{Host: "theirs.example.com", Attempts: 99,
1881 Class: certwarm.ClassOther, NextAttemptAt: now.Add(time.Hour).Unix()}.Encode(now)
1882 if err != nil {
1883 t.Fatal(err)
1884 }
1885 if _, err := fx.rel.Put(ctx, certwarm.Key("theirs.example.com"), body, ""); err != nil {
1886 t.Fatal(err)
1887 }
1888 _, _, out := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "")
1889 var list struct {
1890 Domains []struct {
1891 Host string `json:"host"`
1892 State string `json:"state"`
1893 } `json:"domains"`
1894 }
1895 if err := json.Unmarshal([]byte(out), &list); err != nil {
1896 t.Fatal(err)
1897 }
1898 if len(list.Domains) != 1 || list.Domains[0].Host != "mine.example.com" {
1899 t.Fatalf("list = %+v", list.Domains)
1900 }
1901 if list.Domains[0].State != StateIssuingCert {
1902 t.Errorf("state = %q, want %q — another hostname's failure record was joined to this one",
1903 list.Domains[0].State, StateIssuingCert)
1904 }
1905}
1906