Files
member-console/internal/domains/authorizer_test.go
T
cgalo5758 8d05934e93 Add domains registry with claims and placements
Domain names become an allocatable resource with one authority. A new
core module (schema `domains`, own migration stream between core and the
integrations) owns claims — a DNS node plus its whole subtree, mutually
disjoint: operator shared-domain roots, member claims carved from them,
and bring-your-own names proven by TXT verification — and placements,
which bind a name inside a claim to a provider slug and resource ref.

Verification moves to the claim and decouples from creation. A member
proves control of a domain once; afterwards every name inside it places
instantly, wildcard-CNAME friendly, with no further DNS work. The claim
workflow activates the claim and stops — it no longer creates a site —
so the sites list offers a one-click create once a domain verifies.

/domains/ask answers from placements and is registered by core rather
than the FedWiki adapter; its HTTP contract is unchanged. A configured
`domains-ask-fallback-url` forwards names the registry does not know to
a legacy answerer, the strangler seam wiki.cafe's migration needs; a
name the registry knows but has archived is refused locally.

FedWiki's create saga reserves the name before the farm call, carrying a
workflow-minted site id so retries are idempotent, and compensates on
failure. Sync places only names it owns, never stealing a member's;
lifecycle transitions and the retention purge maintain servability. An
unconditional boot pass seeds operator roots, releases orphaned
placements, and adopts pre-existing sites — grandfathering member-owned
external domains shortest-name-first, and skipping name policy, so a
live single-letter site cannot lose its certificate.

Members manage domains at /domains: claims with verification status, DNS
records including an optional wildcard row, check-now, cancel, release.
Name policy (reserved, blocked, premium, plus a single-letter guard) is
operator data; refusals collapse to a plain "unavailable" so the console
never becomes an oracle for who holds what.

BREAKING (pre-release): `fedwiki.custom_domain_verifications` and
`sites.is_custom_domain` are dropped, the flag now derived from the
placement's claim kind; resource key `fedwiki_custom_domains` migrates
to the platform-owned `external_domain_claims`; running
verify-custom-domain workflows must be terminated before deploy.
2026-07-24 21:25:40 -05:00

292 lines
11 KiB
Go

package domains
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/dnsname"
)
func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
// errQuerier fails the ask lookup, standing in for a database outage: that
// error must surface (the handler answers 500) rather than being laundered
// into a refusal or a fallback call.
type errQuerier struct {
Querier
err error
}
func (e errQuerier) GetPlacementByFQDN(context.Context, string) (Placement, error) {
return Placement{}, e.err
}
// askFallbackServer records every request the legacy answerer receives, so
// tests can assert both what was forwarded and — for the local-refusal
// cases — that nothing was.
type askFallbackServer struct {
*httptest.Server
hits atomic.Int64
queries chan url.Values
}
func newAskFallbackServer(t *testing.T, status int) *askFallbackServer {
t.Helper()
s := &askFallbackServer{queries: make(chan url.Values, 8)}
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.hits.Add(1)
s.queries <- r.URL.Query()
w.WriteHeader(status)
}))
t.Cleanup(s.Close)
return s
}
// placement builds a registry row for fqdn without going through the
// allocation API; only fqdn and servability matter to the authorizer.
func placement(fqdn string, servable bool) Placement {
return Placement{
Fqdn: fqdn,
ReversedLabels: dnsname.ReverseLabels(fqdn),
Provider: "testprovider",
ResourceRef: fqdn,
Servable: servable,
}
}
// TestRegistryAuthorizerWithoutFallback covers the three registry outcomes
// when no legacy answerer is configured (the default posture): servable row
// authorizes, unservable row refuses, missing row refuses.
func TestRegistryAuthorizerWithoutFallback(t *testing.T) {
q := &fakeQuerier{placements: []Placement{
placement("alice.example.test", true),
placement("archived.example.test", false),
}}
auth := NewRegistryAuthorizer(q, "", discardLogger())
tests := []struct {
name string
fqdn string
want bool
}{
{"servable placement authorized", "alice.example.test", true},
{"unservable placement refused", "archived.example.test", false},
{"registry miss refused", "nope.example.test", false},
{"child of a placement not implicitly authorized", "blog.alice.example.test", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := auth.AuthorizeDomain(context.Background(), tt.fqdn)
if err != nil {
t.Fatalf("AuthorizeDomain(%q) error = %v", tt.fqdn, err)
}
if got != tt.want {
t.Errorf("AuthorizeDomain(%q) = %v, want %v", tt.fqdn, got, tt.want)
}
})
}
}
// TestRegistryAuthorizerLookupError checks that a real query failure is
// propagated: the handler turns it into a 500, which is distinguishable
// from a refusal and never reaches the fallback.
func TestRegistryAuthorizerLookupError(t *testing.T) {
fallback := newAskFallbackServer(t, http.StatusOK)
boom := errors.New("db down")
auth := NewRegistryAuthorizer(errQuerier{err: boom}, fallback.URL, discardLogger())
got, err := auth.AuthorizeDomain(context.Background(), "alice.example.test")
if !errors.Is(err, boom) {
t.Fatalf("error = %v, want %v", err, boom)
}
if got {
t.Error("AuthorizeDomain authorized despite a lookup error")
}
if hits := fallback.hits.Load(); hits != 0 {
t.Errorf("fallback consulted %d times after a lookup error, want 0", hits)
}
}
// TestRegistryAuthorizerFallbackAuthorizesMiss covers the strangler seam's
// happy path and the request-building contract: the configured URL's path
// and pre-existing query survive, and `domain` is set through url.Values
// (exactly one value, overriding whatever the operator left there) rather
// than concatenated onto the URL.
func TestRegistryAuthorizerFallbackAuthorizesMiss(t *testing.T) {
fallback := newAskFallbackServer(t, http.StatusOK)
// A configured query carrying a literal "&" proves the encoding is a
// round-trip through url.Values, not string surgery.
configured := fallback.URL + "/ask?token=" + url.QueryEscape("a&b=c") + "&domain=stale.example.test"
auth := NewRegistryAuthorizer(&fakeQuerier{}, configured, discardLogger())
got, err := auth.AuthorizeDomain(context.Background(), "legacy.example.test")
if err != nil {
t.Fatalf("AuthorizeDomain error = %v", err)
}
if !got {
t.Error("AuthorizeDomain refused a miss the fallback answered 200 for")
}
if hits := fallback.hits.Load(); hits != 1 {
t.Fatalf("fallback consulted %d times, want 1", hits)
}
query := <-fallback.queries
if len(query["domain"]) != 1 || query.Get("domain") != "legacy.example.test" {
t.Errorf("forwarded domain = %v, want exactly [legacy.example.test]", query["domain"])
}
if query.Get("token") != "a&b=c" {
t.Errorf("configured token param = %q, want %q", query.Get("token"), "a&b=c")
}
}
// TestRegistryAuthorizerFallbackFailsClosed covers every non-200 outcome of
// the forwarded request: refusal, timeout, and an unreachable answerer all
// refuse without surfacing an error.
func TestRegistryAuthorizerFallbackFailsClosed(t *testing.T) {
t.Run("non-200 refusal", func(t *testing.T) {
fallback := newAskFallbackServer(t, http.StatusNotFound)
auth := NewRegistryAuthorizer(&fakeQuerier{}, fallback.URL, discardLogger())
got, err := auth.AuthorizeDomain(context.Background(), "legacy.example.test")
if err != nil || got {
t.Fatalf("AuthorizeDomain = (%v, %v), want (false, nil)", got, err)
}
if hits := fallback.hits.Load(); hits != 1 {
t.Errorf("fallback consulted %d times, want 1", hits)
}
})
t.Run("timeout", func(t *testing.T) {
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(500 * time.Millisecond)
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(slow.Close)
auth := NewRegistryAuthorizer(&fakeQuerier{}, slow.URL, discardLogger())
// Shorten the production timeout so the test does not wait it out;
// the behaviour under test is what happens when it elapses.
auth.httpClient.Timeout = 20 * time.Millisecond
got, err := auth.AuthorizeDomain(context.Background(), "legacy.example.test")
if err != nil || got {
t.Fatalf("AuthorizeDomain = (%v, %v), want (false, nil)", got, err)
}
})
t.Run("unreachable", func(t *testing.T) {
dead := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
deadURL := dead.URL
dead.Close()
auth := NewRegistryAuthorizer(&fakeQuerier{}, deadURL, discardLogger())
got, err := auth.AuthorizeDomain(context.Background(), "legacy.example.test")
if err != nil || got {
t.Fatalf("AuthorizeDomain = (%v, %v), want (false, nil)", got, err)
}
})
t.Run("malformed fallback URL disables forwarding", func(t *testing.T) {
auth := NewRegistryAuthorizer(&fakeQuerier{}, "not-a-url", discardLogger())
if auth.fallback != nil {
t.Fatal("malformed fallback URL was accepted")
}
got, err := auth.AuthorizeDomain(context.Background(), "legacy.example.test")
if err != nil || got {
t.Fatalf("AuthorizeDomain = (%v, %v), want (false, nil)", got, err)
}
})
}
// TestRegistryAuthorizerMalformedURLIsNotLogged pins the secret-handling half
// of the malformed-URL path: the operator's configured value may embed
// userinfo credentials, so neither it nor url.Parse's error (which quotes its
// input verbatim) may reach the log. Only the parsed host survives.
func TestRegistryAuthorizerMalformedURLIsNotLogged(t *testing.T) {
const secret = "hunter2"
tests := []struct {
name string
url string
wantHost string // "" when no host can be salvaged
}{
// A control character defeats url.Parse entirely, so the error text
// is the leak vector and there is no host to report.
{"unparseable with credentials", "https://svc:" + secret + "@legacy.example.test/ask\n", ""},
// Parses, but "svc" becomes the scheme and the credentials land in
// Opaque — which is why the scheme is dropped too.
{"no scheme, credentials in opaque", "svc:" + secret + "@legacy.example.test/ask", ""},
// Scheme-relative: a real host survives, and the secret must not.
{"no scheme, host present", "//svc:" + secret + "@legacy.example.test/ask", "legacy.example.test"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var logged bytes.Buffer
auth := NewRegistryAuthorizer(&fakeQuerier{}, tt.url,
slog.New(slog.NewTextHandler(&logged, nil)))
if auth.fallback != nil {
t.Fatal("malformed fallback URL was accepted")
}
out := logged.String()
if out == "" {
t.Fatal("malformed fallback URL was not logged at all")
}
if strings.Contains(out, secret) {
t.Errorf("log leaked the configured credentials: %s", out)
}
if strings.Contains(out, "/ask") {
t.Errorf("log leaked the configured path: %s", out)
}
if tt.wantHost != "" && !strings.Contains(out, tt.wantHost) {
t.Errorf("log dropped the host %q, leaving nothing to diagnose: %s", tt.wantHost, out)
}
})
}
}
// TestRegistryAuthorizerNeverForwards pins the two cases where the fallback
// is configured but must not be consulted at all: a registry hit decides
// locally either way (an archived name must not be resurrected by the
// legacy answerer), and a name that is not a well-formed FQDN is refused
// before any outbound call, capping amplification from the unauthenticated
// route.
func TestRegistryAuthorizerNeverForwards(t *testing.T) {
tests := []struct {
name string
fqdn string
want bool
}{
{"servable placement decided locally", "alice.example.test", true},
{"unservable placement never falls through", "archived.example.test", false},
{"single-label name is not a FQDN", "localhost", false},
{"empty name", "", false},
{"query-injection attempt is refused before dialing", "evil.example.test&domain=victim.example.test", false},
{"parameter-stuffed name is refused before dialing", "a.test?x=1", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fallback := newAskFallbackServer(t, http.StatusOK)
q := &fakeQuerier{placements: []Placement{
placement("alice.example.test", true),
placement("archived.example.test", false),
}}
auth := NewRegistryAuthorizer(q, fallback.URL, discardLogger())
got, err := auth.AuthorizeDomain(context.Background(), tt.fqdn)
if err != nil {
t.Fatalf("AuthorizeDomain(%q) error = %v", tt.fqdn, err)
}
if got != tt.want {
t.Errorf("AuthorizeDomain(%q) = %v, want %v", tt.fqdn, got, tt.want)
}
if hits := fallback.hits.Load(); hits != 0 {
t.Errorf("fallback consulted %d times for %q, want 0", hits, tt.fqdn)
}
})
}
}