Files
member-console/internal/server/member_domains_scope_test.go
T
cgalo5758 ad7a219adf Enforce schema and boot invariants
Enforce 10j's verified gaps (schema-hardening change):

- Migration 00010: partial unique indexes for one default pool and one
  primary assignment per workspace, plus CHECKs pinning
  pool/provider/subscription vocabularies and provider lifecycle
  timestamps.
- Workspace creation shares a transactional provisioning function;
  extension validates its target pool; last-tier deletion of a defaulted
  ladder is guarded; signup completes plan-less on a broken ladder.
- Boot asserts integration slug parity and validates declared config
  enums; Stripe invoice amounts are range-checked; domain cancellation
  runs a final evidence probe; rule authoring is additive-only.
2026-08-22 18:02:46 -05:00

569 lines
22 KiB
Go

package server_test
import (
"context"
"database/sql"
"fmt"
"html"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/alexedwards/scs/v2"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
"git.coopcloud.tech/wiki-cafe/member-console/internal/systemtenant"
)
// grantedEntitlements is a stand-in for the entitlements store on the Domains
// surface's gate path. The embedded nil interface satisfies the type without
// implementing methods this surface never calls — if one is ever called, the
// nil panic is the intended, loud failure.
type grantedEntitlements struct {
entitlements.Querier
granted bool
}
func (s grantedEntitlements) GetPrimaryPoolAssignmentByWorkspace(context.Context, string) (entitlements.PoolAssignment, error) {
return entitlements.PoolAssignment{PoolID: "pool-1"}, nil
}
func (s grantedEntitlements) GetBooleanEntitlementByPoolAndResource(context.Context, entitlements.GetBooleanEntitlementByPoolAndResourceParams) (entitlements.BooleanEntitlement, error) {
if !s.granted {
// A never-conferred key has no row at all (entitlements spec), which
// is exactly "not granted".
return entitlements.BooleanEntitlement{}, sql.ErrNoRows
}
return entitlements.BooleanEntitlement{Granted: true}, nil
}
// domainsTestEnv is one Domains-surface handler plus the database behind it.
type domainsTestEnv struct {
handler *server.MemberDomainsHandler
registry *domains.Registry
database *sql.DB
}
// newDomainsTestEnv builds a Domains handler over the real registry. The
// Temporal client stays nil: every path exercised here either refuses before
// a workflow would start, or acts on a claim that already exists, and the
// handlers treat a missing Temporal client as "signal best-effort".
func newDomainsTestEnv(t *testing.T, connectTarget string, granted bool) *domainsTestEnv {
t.Helper()
database := testDB(t)
if _, err := database.ExecContext(context.Background(),
`INSERT INTO core.providers (slug, provider_kind, display_name)
VALUES ('testprovider', 'provisioning', 'Test Provider')
ON CONFLICT (slug) DO NOTHING`); err != nil {
t.Fatalf("fixture provider: %v", err)
}
// Registries in this env get an instant no-record DNS stub so the cancel
// path's evidence probe never touches the real resolver from a test.
noTXT := func(ctx context.Context, name string) ([]string, error) {
return nil, &net.DNSError{Name: name, IsNotFound: true}
}
handler, err := server.NewMemberDomainsHandler(server.MemberDomainsConfig{
DomainsQ: domains.New(database),
Registry: domains.NewRegistry(database, domains.WithEvidenceLookup(noTXT)),
EntitlementsQ: grantedEntitlements{granted: granted},
Logger: discardLogger(),
ConnectTarget: connectTarget,
})
if err != nil {
t.Fatalf("new handler: %v", err)
}
return &domainsTestEnv{handler: handler, registry: domains.NewRegistry(database, domains.WithEvidenceLookup(noTXT)), database: database}
}
// sessionFor returns a request context carrying an authenticated session for
// workspaceID, and installs the matching auth config on the handler.
func (env *domainsTestEnv) sessionFor(t *testing.T, workspaceID string) context.Context {
t.Helper()
sm := scs.New()
sctx, err := sm.Load(context.Background(), "")
if err != nil {
t.Fatalf("load session: %v", err)
}
sm.Put(sctx, "authenticated", true)
sm.Put(sctx, "workspace_id", workspaceID)
env.handler.AuthConfig = &auth.Config{SessionManager: sm}
return sctx
}
// newDomainsWorkspace builds the user → person → organization → workspace
// chain a claim's FK needs and returns the workspace id.
func newDomainsWorkspace(t *testing.T, database *sql.DB, label string) string {
t.Helper()
ctx := context.Background()
uniq := fmt.Sprintf("%s-%d", label, time.Now().UnixNano())
scan := func(query string, args ...any) string {
var id string
if err := database.QueryRowContext(ctx, query, args...).Scan(&id); err != nil {
t.Fatalf("fixture %q: %v", query, err)
}
return id
}
userID := scan(`INSERT INTO core.users (oidc_subject) VALUES ($1) RETURNING user_id`, "sub-"+uniq)
personID := scan(`INSERT INTO core.persons (user_id, display_name, primary_email) VALUES ($1,$2,$3) RETURNING person_id`,
userID, "Domains Tester", uniq+"@example.com")
orgID := scan(`INSERT INTO core.organizations (name, slug, org_type, owner_person_id) VALUES ($1,$2,'personal',$3) RETURNING org_id`,
"Org "+uniq, "org-"+uniq, personID)
return scan(`INSERT INTO core.workspaces (org_id, name, slug) VALUES ($1,$2,$3) RETURNING workspace_id`,
orgID, "WS", "ws-"+uniq)
}
// activate promotes a pending claim the way the verification workflow would.
func activateClaim(t *testing.T, database *sql.DB, claimID string) {
t.Helper()
if _, err := database.ExecContext(context.Background(),
`UPDATE domains.claims SET status='active', verified_at=NOW() WHERE claim_id=$1`, claimID); err != nil {
t.Fatalf("activate claim: %v", err)
}
}
func postForm(ctx context.Context, path string, values url.Values) *http.Request {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return req.WithContext(ctx)
}
// TestMemberDomainsOwnerScoping is the security property of the whole
// surface: a claim another workspace owns must be indistinguishable from one
// that does not exist, on EVERY route that takes a claim id. A 404 on all of
// them is what keeps an id probe from becoming an existence oracle.
func TestMemberDomainsOwnerScoping(t *testing.T) {
env := newDomainsTestEnv(t, "connect.example.test", true)
ctx := context.Background()
owner := newDomainsWorkspace(t, env.database, "owner")
stranger := newDomainsWorkspace(t, env.database, "stranger")
uniq := fmt.Sprintf("%d", time.Now().UnixNano())
claim, err := env.registry.ClaimExternal(ctx, owner, "owned"+uniq+".example")
if err != nil {
t.Fatalf("claim: %v", err)
}
sctx := env.sessionFor(t, stranger)
base := "/partials/domains/claims/" + claim.ClaimID
cases := map[string]struct {
req *http.Request
handler http.HandlerFunc
}{
"status": {httptest.NewRequest(http.MethodGet, base, nil).WithContext(sctx), env.handler.GetClaimStatus},
"check": {postForm(sctx, base+"/check", nil), env.handler.CheckClaimNow},
"cancel": {postForm(sctx, base+"/cancel", nil), env.handler.CancelClaim},
"release": {postForm(sctx, base+"/release", nil), func(w http.ResponseWriter, r *http.Request) {
env.handler.ReleaseClaim(w, r)
}},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
tc.req.SetPathValue("claimID", claim.ClaimID)
rec := httptest.NewRecorder()
tc.handler(rec, tc.req)
if rec.Code != http.StatusNotFound {
t.Fatalf("foreign claim %s = %d, want 404\n%s", name, rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "owned"+uniq) {
t.Error("a 404 must not echo the domain it refused to talk about")
}
})
}
// The claim is untouched: nothing a stranger did may have marked it.
fresh, err := env.registry.ClaimByID(ctx, owner, claim.ClaimID)
if err != nil {
t.Fatalf("re-read claim: %v", err)
}
if fresh.Status != domains.StatusPending {
t.Errorf("claim status = %q after stranger's attempts, want pending", fresh.Status)
}
// And the stranger's own list shows none of it.
listReq := httptest.NewRequest(http.MethodGet, "/partials/domains/claims", nil).WithContext(sctx)
rec := httptest.NewRecorder()
env.handler.GetClaims(rec, listReq)
if rec.Code != http.StatusOK {
t.Fatalf("list = %d, want 200", rec.Code)
}
if strings.Contains(rec.Body.String(), "owned"+uniq) {
t.Error("claims list leaked another workspace's claim")
}
}
// TestMemberDomainsGateRendering covers design D7's two-part gate on the live
// handler, post-dissolution (dissolve-member-domains): the claims surface
// renders no add affordance under ANY gate state — adding starts from the
// site-creation flow, and TestMemberDomainsNoAddForm pins the same contract
// at the template layer — while the still-routed POST re-enforces the gate
// server-side with copy that names the reason: the plan when it is the
// member's to fix, the deployment when it is not, and neither when both
// halves are open.
func TestMemberDomainsGateRendering(t *testing.T) {
ctx := context.Background()
gateRefusals := []string{"Ask your operator", "part of your current plan"}
for name, tc := range map[string]struct {
target string
granted bool
wantRefusal string // empty = both gate halves open
}{
"entitled and configured": {"connect.example.test", true, ""},
"entitled, no target": {"", true, "Ask your operator"},
"configured, not entitled": {"connect.example.test", false, "part of your current plan"},
} {
t.Run(name, func(t *testing.T) {
env := newDomainsTestEnv(t, tc.target, tc.granted)
workspace := newDomainsWorkspace(t, env.database, "gate")
sctx := env.sessionFor(t, workspace)
rec := httptest.NewRecorder()
env.handler.GetClaims(rec, httptest.NewRequest(http.MethodGet, "/partials/domains/claims", nil).WithContext(sctx))
if rec.Code != http.StatusOK {
t.Fatalf("list = %d, want 200", rec.Code)
}
for _, unwanted := range []string{`hx-post="/partials/domains/claims"`, "Add a domain you own", `name="domain"`} {
if strings.Contains(rec.Body.String(), unwanted) {
t.Errorf("claims surface must not render the add form (%q)", unwanted)
}
}
// The gate lives on POST, not in the markup.
uniq := fmt.Sprintf("%d", time.Now().UnixNano())
postRec := httptest.NewRecorder()
env.handler.AddClaim(postRec, postForm(sctx, "/partials/domains/claims",
url.Values{"domain": {"gate" + uniq + ".example"}}))
if postRec.Code != http.StatusOK {
t.Fatalf("POST = %d, want a rendered 200", postRec.Code)
}
if tc.wantRefusal == "" {
// Both halves open: no gate refusal renders. (This env has no
// Temporal client, so the claim itself is refused later with
// verification-unavailable copy — a different, honestly named
// refusal; allocation success is covered by
// TestMemberDomainsAddClaimBranchTable.)
for _, refusal := range gateRefusals {
if strings.Contains(postRec.Body.String(), refusal) {
t.Errorf("open gate rendered gate-refusal copy %q\n%s", refusal, postRec.Body.String())
}
}
return
}
if !strings.Contains(postRec.Body.String(), tc.wantRefusal) {
t.Errorf("refusal missing %q\n%s", tc.wantRefusal, postRec.Body.String())
}
// Nothing was allocated behind the closed gate.
claims, err := env.registry.ListLiveClaims(ctx, workspace)
if err != nil {
t.Fatalf("list claims: %v", err)
}
if len(claims) != 0 {
t.Errorf("refused POST allocated %d claim(s)", len(claims))
}
})
}
}
// TestMemberDomainsAddClaimBranchTable pins the add-a-domain dispositions,
// including the non-disclosure rule: the caller's OWN pending claim is named
// and linked, while another workspace's claim — pending or active — is
// indistinguishable from any other refusal.
func TestMemberDomainsAddClaimBranchTable(t *testing.T) {
env := newDomainsTestEnv(t, "connect.example.test", true)
ctx := context.Background()
uniq := fmt.Sprintf("%d", time.Now().UnixNano())
systemWS, err := systemtenant.Ensure(ctx, env.database)
if err != nil {
t.Fatalf("ensure system tenant: %v", err)
}
operatorRoot := "hosting" + uniq + ".test"
if _, err := env.registry.EnsureOperatorRoot(ctx, systemWS, operatorRoot); err != nil {
t.Fatalf("ensure operator root: %v", err)
}
caller := newDomainsWorkspace(t, env.database, "caller")
other := newDomainsWorkspace(t, env.database, "other")
ownPending := "ownpending" + uniq + ".example"
pendingClaim, err := env.registry.ClaimExternal(ctx, caller, ownPending)
if err != nil {
t.Fatalf("claim own pending: %v", err)
}
ownActive := "ownactive" + uniq + ".example"
activeClaim, err := env.registry.ClaimExternal(ctx, caller, ownActive)
if err != nil {
t.Fatalf("claim own active: %v", err)
}
activateClaim(t, env.database, activeClaim.ClaimID)
foreign := "foreign" + uniq + ".example"
if _, err := env.registry.ClaimExternal(ctx, other, foreign); err != nil {
t.Fatalf("claim foreign: %v", err)
}
// Escaped: the collapsed refusal carries an apostrophe, which the
// template renders as an entity.
unavailable := html.EscapeString(domains.Availability{Verdict: domains.VerdictTaken}.MemberMessage())
sctx := env.sessionFor(t, caller)
for name, tc := range map[string]struct {
domain string
wantCopy string
wantLink bool
wantClaim string
}{
"own pending claim points at its records": {ownPending, "already verifying", true, pendingClaim.ClaimID},
"own active claim needs no second claim": {ownActive, "You already have that domain", false, ""},
"another workspace collapses to generic": {foreign, unavailable, false, ""},
"nested under another workspace's claim": {"deep." + foreign, unavailable, false, ""},
"operator root belongs to the hosted path": {"mine" + uniq + "." + operatorRoot,
"one of ours", false, ""},
"malformed name explains its shape": {"not a domain", "domain", false, ""},
} {
t.Run(name, func(t *testing.T) {
rec := httptest.NewRecorder()
env.handler.AddClaim(rec, postForm(sctx, "/partials/domains/claims", url.Values{"domain": {tc.domain}}))
if rec.Code != http.StatusOK {
t.Fatalf("AddClaim = %d, want a rendered 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, tc.wantCopy) {
t.Errorf("refusal missing %q\n%s", tc.wantCopy, body)
}
hasLink := strings.Contains(body, "See its DNS records")
if hasLink != tc.wantLink {
t.Errorf("records link present = %v, want %v", hasLink, tc.wantLink)
}
if tc.wantClaim != "" && !strings.Contains(body, tc.wantClaim) {
t.Errorf("expected a link to claim %s\n%s", tc.wantClaim, body)
}
})
}
}
// TestMemberDomainsAddClaimNormalizesInput: the registry stores and compares
// normalized names, so the handler must normalize on the way in or the two
// disagree — an absolute, mixed-case spelling would read as a different name
// than the claim it actually refers to, and the refused form would echo back
// a spelling the registry does not hold.
func TestMemberDomainsAddClaimNormalizesInput(t *testing.T) {
env := newDomainsTestEnv(t, "connect.example.test", true)
ctx := context.Background()
uniq := fmt.Sprintf("%d", time.Now().UnixNano())
workspace := newDomainsWorkspace(t, env.database, "normalize")
sctx := env.sessionFor(t, workspace)
root := "mixed" + uniq + ".example"
claim, err := env.registry.ClaimExternal(ctx, workspace, root)
if err != nil {
t.Fatalf("claim: %v", err)
}
// Same name, spelled absolutely and in upper case.
shouted := strings.ToUpper(root) + "."
rec := httptest.NewRecorder()
env.handler.AddClaim(rec, postForm(sctx, "/partials/domains/claims", url.Values{"domain": {shouted}}))
if rec.Code != http.StatusOK {
t.Fatalf("AddClaim = %d, want a rendered 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "already verifying") || !strings.Contains(body, claim.ClaimID) {
t.Errorf("an absolute, upper-case spelling did not resolve to the existing claim:\n%s", body)
}
if strings.Contains(body, shouted) {
t.Errorf("the form echoed %q back; the registry holds %q", shouted, root)
}
if !strings.Contains(body, root) {
t.Errorf("the form should echo the normalized name %q:\n%s", root, body)
}
// And no second claim was minted for the alternate spelling.
claims, err := env.registry.ListLiveClaims(ctx, workspace)
if err != nil {
t.Fatalf("list claims: %v", err)
}
if len(claims) != 1 {
t.Errorf("live claims = %d, want 1", len(claims))
}
}
// TestMemberDomainsReleaseGuard covers the placement guard (spec: "Release is
// guarded by placements"): a claim still serving a name is refused, and the
// message tells the member which sites to remove. Once the placement is gone,
// the same request succeeds.
func TestMemberDomainsReleaseGuard(t *testing.T) {
env := newDomainsTestEnv(t, "connect.example.test", true)
ctx := context.Background()
uniq := fmt.Sprintf("%d", time.Now().UnixNano())
workspace := newDomainsWorkspace(t, env.database, "release")
root := "release" + uniq + ".example"
claim, err := env.registry.ClaimExternal(ctx, workspace, root)
if err != nil {
t.Fatalf("claim: %v", err)
}
activateClaim(t, env.database, claim.ClaimID)
placement, err := env.registry.Place(ctx, domains.PlaceParams{
WorkspaceID: workspace, FQDN: "wiki." + root,
Provider: "testprovider", ResourceRef: "res-1", Servable: true,
})
if err != nil {
t.Fatalf("place: %v", err)
}
sctx := env.sessionFor(t, workspace)
req := postForm(sctx, "/partials/domains/claims/"+claim.ClaimID+"/release", nil)
req.SetPathValue("claimID", claim.ClaimID)
rec := httptest.NewRecorder()
env.handler.ReleaseClaim(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("guarded release = %d, want a rendered 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Remove those sites first") || !strings.Contains(body, "wiki."+root) {
t.Errorf("guard message should name the placement blocking the release:\n%s", body)
}
fresh, err := env.registry.ClaimByID(ctx, workspace, claim.ClaimID)
if err != nil {
t.Fatalf("re-read claim: %v", err)
}
if fresh.Status != domains.StatusActive {
t.Errorf("refused release changed status to %q", fresh.Status)
}
// Remove the name; the claim is now releasable.
if _, err := env.registry.ReleasePlacement(ctx, placement.PlacementID); err != nil {
t.Fatalf("release placement: %v", err)
}
req = postForm(sctx, "/partials/domains/claims/"+claim.ClaimID+"/release", nil)
req.SetPathValue("claimID", claim.ClaimID)
rec = httptest.NewRecorder()
env.handler.ReleaseClaim(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("release = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Domain released") {
t.Errorf("release should confirm:\n%s", rec.Body.String())
}
if _, err := env.registry.ClaimByID(ctx, workspace, claim.ClaimID); err != nil {
t.Fatalf("re-read released claim: %v", err)
}
}
// TestMemberDomainsCancelPendingOnly covers both halves of the cancel race
// (design D4): a pending claim cancels and frees its name immediately, while
// a cancel that arrives after the workflow marked the claim active changes
// nothing and shows the member the verified state instead.
func TestMemberDomainsCancelPendingOnly(t *testing.T) {
env := newDomainsTestEnv(t, "connect.example.test", true)
ctx := context.Background()
uniq := fmt.Sprintf("%d", time.Now().UnixNano())
workspace := newDomainsWorkspace(t, env.database, "cancel")
sctx := env.sessionFor(t, workspace)
cancelAndRender := func(claimID string) string {
req := postForm(sctx, "/partials/domains/claims/"+claimID+"/cancel", nil)
req.SetPathValue("claimID", claimID)
rec := httptest.NewRecorder()
env.handler.CancelClaim(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("cancel = %d, want a rendered 200", rec.Code)
}
return rec.Body.String()
}
// Pending: cancel wins and the name is immediately claimable again.
pendingRoot := "cancelme" + uniq + ".example"
pending, err := env.registry.ClaimExternal(ctx, workspace, pendingRoot)
if err != nil {
t.Fatalf("claim: %v", err)
}
if body := cancelAndRender(pending.ClaimID); !strings.Contains(body, "was canceled") {
t.Errorf("cancel view missing its copy:\n%s", body)
}
if av, err := env.registry.CheckAvailability(ctx, pendingRoot, workspace); err != nil {
t.Fatalf("availability: %v", err)
} else if av.Verdict != domains.VerdictAvailable {
t.Errorf("canceled name verdict = %q, want available immediately", av.Verdict)
}
// Active: the cancel lost the race — the claim stays active and the
// member sees the verified state, not a false "canceled".
activeRoot := "raced" + uniq + ".example"
active, err := env.registry.ClaimExternal(ctx, workspace, activeRoot)
if err != nil {
t.Fatalf("claim: %v", err)
}
activateClaim(t, env.database, active.ClaimID)
body := cancelAndRender(active.ClaimID)
if !strings.Contains(body, "is verified ✓") {
t.Errorf("a cancel that lost the race must show the verified state:\n%s", body)
}
if strings.Contains(body, "was canceled") {
t.Error("a cancel that changed no row must not claim success")
}
fresh, err := env.registry.ClaimByID(ctx, workspace, active.ClaimID)
if err != nil {
t.Fatalf("re-read claim: %v", err)
}
if fresh.Status != domains.StatusActive {
t.Errorf("claim status = %q, want it left active", fresh.Status)
}
}
// TestMemberDomainsStatusViewIsAPureRead covers the spec's reopen rule:
// opening a pending claim's instructions re-reads the claim and mints
// nothing — same claim id, same token, same records, no second claim.
func TestMemberDomainsStatusViewIsAPureRead(t *testing.T) {
env := newDomainsTestEnv(t, "connect.example.test", true)
ctx := context.Background()
uniq := fmt.Sprintf("%d", time.Now().UnixNano())
workspace := newDomainsWorkspace(t, env.database, "reopen")
sctx := env.sessionFor(t, workspace)
root := "reopen" + uniq + ".example"
claim, err := env.registry.ClaimExternal(ctx, workspace, root)
if err != nil {
t.Fatalf("claim: %v", err)
}
open := func() string {
req := httptest.NewRequest(http.MethodGet, "/partials/domains/claims/"+claim.ClaimID, nil).WithContext(sctx)
req.SetPathValue("claimID", claim.ClaimID)
rec := httptest.NewRecorder()
env.handler.GetClaimStatus(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
return rec.Body.String()
}
first, second := open(), open()
// The wildcard row is part of what a reopen shows, per design D9.
for _, want := range []string{"*." + root, "connect.example.test", "Optional", claim.Token} {
if !strings.Contains(first, want) {
t.Errorf("instructions missing %q", want)
}
}
if first != second {
t.Error("reopening the instructions changed them — records must be stable")
}
claims, err := env.registry.ListLiveClaims(ctx, workspace)
if err != nil {
t.Fatalf("list claims: %v", err)
}
if len(claims) != 1 {
t.Errorf("live claims = %d after two reopens, want 1", len(claims))
}
}