| index ddf3645dc..29173035a 100644 |
| --- a/internal/console/apidomains_test.go |
| +++ b/internal/console/apidomains_test.go |
| @@ -14,15 +14,20 @@ import ( |
| "context" |
| "encoding/json" |
| "errors" |
| + "fmt" |
| "net/http" |
| + "net/http/httptest" |
| "net/url" |
| "os" |
| + "path/filepath" |
| + "sort" |
| "strings" |
| "testing" |
| "time" |
| |
| "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/status" |
| ) |
| |
| @@ -501,7 +506,7 @@ func TestAttachWildcardCNAMEGate(t *testing.T) { |
| t.Run("missing CNAME refuses with the record to create", func(t *testing.T) { |
| fx, member := instanceFixture(t, "code-dom-wc-missing") |
| fx.srv.delegationZone = zone |
| - fx.srv.lookupCNAME = func(string) (string, error) { return "", errors.New("not found") } |
| + fx.srv.lookupCNAME = func(context.Context, string) (string, error) { return "", errors.New("not found") } |
| code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| `{"host":"`+host+`","route":"go.tito.io","wildcard":true}`) |
| if code != http.StatusUnprocessableEntity { |
| @@ -528,7 +533,7 @@ func TestAttachWildcardCNAMEGate(t *testing.T) { |
| t.Run("correct CNAME enables wildcard and bumps the serial", func(t *testing.T) { |
| fx, member := instanceFixture(t, "code-dom-wc-ok") |
| fx.srv.delegationZone = zone |
| - fx.srv.lookupCNAME = func(name string) (string, error) { |
| + fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) { |
| if name == "_acme-challenge."+host { |
| return wantCNAME, nil |
| } |
| @@ -548,10 +553,97 @@ func TestAttachWildcardCNAMEGate(t *testing.T) { |
| } |
| }) |
| |
| + // IMPORTANT-1 (review): DNS names compare case-insensitively (RFC 4343) |
| + // and a resolver answers in the record's own authored case, not the |
| + // zone's. A customer who typed their CNAME target in mixed case must |
| + // still delegate, not read as a permanently broken/missing record. |
| + t.Run("mixed-case CNAME target still delegates", func(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-wc-mixedcase") |
| + fx.srv.delegationZone = zone |
| + mixedCase := "_ACME-Challenge." + host + "." + strings.ToUpper(zone) + "." |
| + fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) { |
| + if name == "_acme-challenge."+host { |
| + return mixedCase, nil |
| + } |
| + return "", errors.New("not found") |
| + } |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| + `{"host":"`+host+`","route":"go.tito.io","wildcard":true}`) |
| + if code != http.StatusOK { |
| + t.Fatalf("attach with a mixed-case CNAME target = %d %s, want 200", code, out) |
| + } |
| + if c := claimOf(t, fx, host); !c.Wildcard { |
| + t.Fatalf("claim after a mixed-case CNAME enable = %+v, want Wildcard true", *c) |
| + } |
| + }) |
| + |
| + // IMPORTANT-2 (review): re-sending wildcard:true for a claim that |
| + // already has it must not re-run the CNAME lookup at all — proven here |
| + // by a stub that always errors (simulating a resolver blip) plus a call |
| + // counter: if the gate wrongly re-checked on every idempotent re-attach, |
| + // this would 422 instead of succeeding, and the counter would be |
| + // nonzero. |
| + t.Run("re-sending wildcard true on an already-wildcard claim skips the lookup", func(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-wc-idempotent") |
| + fx.srv.delegationZone = zone |
| + calls := 0 |
| + fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) { |
| + if name == "_acme-challenge."+host { |
| + return wantCNAME, nil |
| + } |
| + return "", errors.New("not found") |
| + } |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| + `{"host":"`+host+`","route":"go.tito.io","wildcard":true}`) |
| + if code != http.StatusOK { |
| + t.Fatalf("seed wildcard enable = %d %s, want 200", code, out) |
| + } |
| + // Now break the resolver entirely (a blip) and count every call. |
| + fx.srv.lookupCNAME = func(context.Context, string) (string, error) { |
| + calls++ |
| + return "", errors.New("resolver unavailable") |
| + } |
| + code, _, out = rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| + `{"host":"`+host+`","route":"go.tito.io","wildcard":true}`) |
| + if code != http.StatusOK { |
| + t.Fatalf("re-sending wildcard:true on an already-wildcard claim = %d %s, want 200 (no re-check)", code, out) |
| + } |
| + if calls != 0 { |
| + t.Fatalf("lookupCNAME was called %d times on an idempotent re-attach, want 0", calls) |
| + } |
| + if c := claimOf(t, fx, host); !c.Wildcard { |
| + t.Fatalf("claim after the idempotent re-attach = %+v, want Wildcard still true", *c) |
| + } |
| + }) |
| + |
| + // IMPORTANT-3 (review): the capability write's own ErrHostUnavailable |
| + // branch (setDomainCapabilities, apidomains.go), forced without a new |
| + // test seam: the lookupCNAME stub DETACHES the claim as a side effect |
| + // (simulating the claim changing hands between the attach write above |
| + // and SetClaimCapabilities's own read) and THEN returns the correct |
| + // CNAME, so the gate passes but SetClaimCapabilities's read finds |
| + // os.ErrNotExist and returns ErrHostUnavailable — which must answer the |
| + // SAME byte-identical 404 every other non-owner refusal does. |
| + t.Run("claim vanishing between attach and capability write answers the byte-identical 404", func(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-wc-vanish") |
| + fx.srv.delegationZone = zone |
| + fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) { |
| + if _, err := domains.DetachClaim(context.Background(), fx.rel, host, "bab", "titogo"); err != nil { |
| + t.Fatal(err) |
| + } |
| + return wantCNAME, nil |
| + } |
| + want := canonicalNotFound(t) |
| + code, h, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| + `{"host":"`+host+`","route":"go.tito.io","wildcard":true}`) |
| + assertNotFound(t, "capability write after the claim vanished mid-request", want, code, h, out) |
| + claimAbsent(t, fx, host) |
| + }) |
| + |
| t.Run("catchall without wildcard refuses and stores nothing", func(t *testing.T) { |
| fx, member := instanceFixture(t, "code-dom-wc-catchall") |
| fx.srv.delegationZone = zone |
| - fx.srv.lookupCNAME = func(string) (string, error) { return "", errors.New("not found") } |
| + fx.srv.lookupCNAME = func(context.Context, string) (string, error) { return "", errors.New("not found") } |
| code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| `{"host":"`+host+`","route":"go.tito.io","catchall":true}`) |
| if code != http.StatusUnprocessableEntity { |
| @@ -568,7 +660,7 @@ func TestAttachWildcardCNAMEGate(t *testing.T) { |
| t.Run("wildcard and catchall together with a correct CNAME store both", func(t *testing.T) { |
| fx, member := instanceFixture(t, "code-dom-wc-both") |
| fx.srv.delegationZone = zone |
| - fx.srv.lookupCNAME = func(name string) (string, error) { |
| + fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) { |
| if name == "_acme-challenge."+host { |
| return wantCNAME, nil |
| } |
| @@ -641,7 +733,7 @@ func TestAttachWildcardDisableCascadesCatchallOff(t *testing.T) { |
| fx, member := instanceFixture(t, "code-dom-wc-disable") |
| fx.srv.delegationZone = zone |
| wantCNAME := "_acme-challenge." + host + "." + zone + "." |
| - fx.srv.lookupCNAME = func(name string) (string, error) { |
| + fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) { |
| if name == "_acme-challenge."+host { |
| return wantCNAME, nil |
| } |
| @@ -653,7 +745,7 @@ func TestAttachWildcardDisableCascadesCatchallOff(t *testing.T) { |
| } |
| // Break the stub: a disable call that still consulted it would now fail |
| // the CNAME check and 422 — proving the disable path skips it entirely. |
| - fx.srv.lookupCNAME = func(string) (string, error) { return "", errors.New("not found") } |
| + fx.srv.lookupCNAME = func(context.Context, string) (string, error) { return "", errors.New("not found") } |
| code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| `{"host":"`+host+`","route":"go.tito.io","wildcard":false}`) |
| if code != http.StatusOK { |
| @@ -677,7 +769,7 @@ func TestDomainsListDelegationStamp(t *testing.T) { |
| const wildHost = "wild.example.com" |
| const plainHost = "plain.example.com" |
| wantCNAME := "_acme-challenge." + wildHost + "." + zone + "." |
| - fx.srv.lookupCNAME = func(name string) (string, error) { |
| + fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) { |
| if name == "_acme-challenge."+wildHost { |
| return wantCNAME, nil |
| } |
| @@ -731,9 +823,157 @@ func TestDomainsListDelegationStamp(t *testing.T) { |
| |
| // Break the CNAME stub: the same claim now reads "missing" — the stamp is |
| // a live read, not something cached from when wildcard was enabled. |
| - fx.srv.lookupCNAME = func(string) (string, error) { return "", errors.New("not found") } |
| + fx.srv.lookupCNAME = func(context.Context, string) (string, error) { return "", errors.New("not found") } |
| m = byHost(list(t)) |
| if row := m[wildHost]; !row.Wildcard || row.DelegationStatus != "missing" { |
| t.Fatalf("%s row after breaking the CNAME = %+v, want wildcard true and missing", wildHost, row) |
| } |
| } |
| + |
| +// TestDomainsListDelegationStampCapsLookups: IMPORTANT-2 (review) bounds the |
| +// list door's live CNAME work at domainListCNAMELookupCap per call. Nine |
| +// wildcard-enabled claims, host-sorted "h0".."h8": only the lexicographically |
| +// first 8 get a delegation_status stamp (and only 8 lookupCNAME calls are |
| +// made at all); the 9th lists with the field simply omitted, same as an |
| +// unset zone or wildcard off — never an error. |
| +func TestDomainsListDelegationStampCapsLookups(t *testing.T) { |
| + const zone = "acme.oncarlos.com" |
| + fx, member := instanceFixture(t, "code-dom-list-cap") |
| + fx.srv.delegationZone = zone |
| + var calls int |
| + fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) { |
| + calls++ |
| + host := strings.TrimPrefix(name, "_acme-challenge.") |
| + return "_acme-challenge." + host + "." + zone + ".", nil |
| + } |
| + hosts := make([]string, 9) |
| + for i := range hosts { |
| + h := fmt.Sprintf("h%d.example.com", i) |
| + hosts[i] = h |
| + attachDomain(t, fx, member, h, "go.tito.io") |
| + if changed, err := domains.SetClaimCapabilities(context.Background(), fx.rel, h, "bab", "titogo", true, false); err != nil || !changed { |
| + t.Fatalf("seed SetClaimCapabilities(%s, wildcard) = changed=%v err=%v", h, changed, err) |
| + } |
| + } |
| + sort.Strings(hosts) |
| + |
| + code, _, out := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("list = %d %s, want 200", code, out) |
| + } |
| + var got domainListResp |
| + if err := json.Unmarshal([]byte(out), &got); err != nil { |
| + t.Fatalf("list body %s: %v", out, err) |
| + } |
| + if len(got.Domains) != 9 { |
| + t.Fatalf("list = %d rows, want 9", len(got.Domains)) |
| + } |
| + byHost := map[string]string{} |
| + for _, d := range got.Domains { |
| + byHost[d.Host] = d.DelegationStatus |
| + } |
| + for i, h := range hosts { |
| + if i < 8 { |
| + if byHost[h] != "delegated" { |
| + t.Errorf("host %d (%s) delegation_status = %q, want delegated (within the cap)", i, h, byHost[h]) |
| + } |
| + } else { |
| + if byHost[h] != "" { |
| + t.Errorf("host %d (%s) delegation_status = %q, want omitted (beyond the cap)", i, h, byHost[h]) |
| + } |
| + } |
| + } |
| + if calls != 8 { |
| + t.Fatalf("lookupCNAME was called %d times, want exactly 8 (the cap)", calls) |
| + } |
| +} |
| + |
| +// ---- zone validation at construction (Task 5 review, IMPORTANT-4) --------- |
| + |
| +// TestNormalizeDelegationZone: CARLOS_ACME_DELEGATION_ZONE is lowercased, |
| +// trimmed, and shape-checked the same way the edge validates the identical |
| +// variable (internal/edge/wildcard.go) — an invalid value degrades to "", |
| +// never a panic or a value handed to lookupCNAME verbatim. |
| +func TestNormalizeDelegationZone(t *testing.T) { |
| + for _, tc := range []struct{ raw, want string }{ |
| + {"", ""}, |
| + {" ", ""}, |
| + {"ACME.OnCarlos.Com", "acme.oncarlos.com"}, |
| + {" acme.oncarlos.com ", "acme.oncarlos.com"}, |
| + {"*.bad zone", ""}, |
| + {"-leading-hyphen.example.com", ""}, |
| + {"has a space.example.com", ""}, |
| + } { |
| + if got := normalizeDelegationZone(tc.raw); got != tc.want { |
| + t.Errorf("normalizeDelegationZone(%q) = %q, want %q", tc.raw, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// zoneFixtureWithDelegation builds a full console fixture the same shape |
| +// instanceFixture does, but with Config.DelegationZone set AT CONSTRUCTION — |
| +// the only way to exercise NewServer's own zone validation (IMPORTANT-4): |
| +// every other test in this file stubs delegationZone directly on an |
| +// already-constructed Server, which bypasses normalizeDelegationZone |
| +// entirely. |
| +func zoneFixtureWithDelegation(t *testing.T, zone, code string) (*consoleFixture, string) { |
| + t.Helper() |
| + regPath, relStore, priv, pub := buildFixtures(t) |
| + st, err := Open(filepath.Join(t.TempDir(), "console.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { st.Close() }) |
| + fk := newFakeKeymail(t, "paul@keymail.dev") |
| + var srv *Server |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + srv.ServeHTTP(w, r) |
| + })) |
| + t.Cleanup(ts.Close) |
| + srv, err = NewServer(Config{ |
| + Origin: ts.URL, Keymail: fk.srv.URL, Registry: regPath, Releases: relStore, |
| + ReleasePub: pub, Key: priv, AppScheme: "http", DelegationZone: zone, |
| + }, st) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv.instances.timeout = 100 * time.Millisecond |
| + if err := st.AddMember(OpsAccountID, "paul@keymail.dev", RoleOwner, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := st.CreateAppIn(OpsAccountID, "kass", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx := &consoleFixture{ts: ts, srv: srv, st: st, fk: fk, rel: relStore, regPath: regPath, |
| + priv: priv, pub: pub, sqid: release.OpsAccount} |
| + now := time.Now() |
| + if _, err := st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + return fx, cliToken(t, fx, "alice@keymail.dev", code) |
| +} |
| + |
| +// TestDelegationZoneInvalidAtConstruction: a garbage |
| +// CARLOS_ACME_DELEGATION_ZONE degrades to delegation-off at Server |
| +// construction (IMPORTANT-4) — a wildcard-enable request against it answers |
| +// the ordinary "no delegation zone configured" 422, the exact same refusal |
| +// an unset zone gets, never a distinguishable error and never a lookupCNAME |
| +// call built from an invalid zone. |
| +func TestDelegationZoneInvalidAtConstruction(t *testing.T) { |
| + fx, member := zoneFixtureWithDelegation(t, "*.bad zone", "code-dom-wc-badzone") |
| + fx.srv.lookupCNAME = func(context.Context, string) (string, error) { |
| + t.Fatal("lookupCNAME must not be called when the configured zone is invalid") |
| + return "", nil |
| + } |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| + `{"host":"woodstar.app","route":"go.tito.io","wildcard":true}`) |
| + if code != http.StatusUnprocessableEntity { |
| + t.Fatalf("attach with wildcard under an invalid configured zone = %d %s, want 422", code, out) |
| + } |
| + if !strings.Contains(out, "no delegation zone") { |
| + t.Fatalf("422 body = %s, want it to mention no delegation zone", out) |
| + } |
| +} |