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.
569 lines
22 KiB
Go
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))
|
|
}
|
|
}
|