Files
member-console/internal/server/member_domains_test.go
T
cgalo5758 d3b222a446 Dissolve the member domains surface into point-of-use
Domains leaves the member nav everywhere; GET /domains 302s to the
dashboard and domains.html is deleted. Claims are managed where they are
used: the fedwiki sites card embeds the core claims partial, a
server-conditional dashboard notice carries pending verifications (the
durable re-entry now that the page is gone), and the member_domains
partials retarget to 'closest .domains-surface' so multiple hosts coexist
on one page. Adding an external domain starts only from the create form;
the fedwiki banner slims to verified-unplaced one-click creates, since
the notice and embedded section own the pending state.

Verified at the surface end-to-end (stack + Chrome): nav absence,
redirect, notice lifecycle through claim-cancel, and cross-host swap
isolation with two claim views open. Archives the change with spec
deltas synced (domains-registry point-of-use rewrite, fedwiki-sites and
member-dashboard additions); files the entitlement-gate placement debt
in issues.md; adds the repo verify skill.
2026-07-26 03:10:23 -05:00

318 lines
12 KiB
Go

package server
import (
"bytes"
"html/template"
"io"
"io/fs"
"log/slog"
"strings"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
// renderMemberDomains parses the member partial set the Domains surface uses
// and renders one template, so Go-template syntax errors surface here rather
// than at request time.
func renderMemberDomains(t *testing.T, name string, data any) string {
t.Helper()
partialsSub, err := fs.Sub(embeds.Templates, "templates/partials")
if err != nil {
t.Fatalf("fs.Sub: %v", err)
}
tmpl, err := template.New("member").Funcs(template.FuncMap{
"routeURL": web.RouteURL,
}).ParseFS(partialsSub, "member_*.html")
if err != nil {
t.Fatalf("ParseFS: %v", err)
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
t.Fatalf("ExecuteTemplate %s: %v", name, err)
}
return buf.String()
}
// TestMemberDomainsKindAndStatusLabels pins the member-facing vocabulary: the
// registry's kinds and statuses are internal words, and the surface must not
// leak them. Operator roots and carved member claims read identically
// ("Hosted") because the distinction is ours, not the member's.
func TestMemberDomainsKindAndStatusLabels(t *testing.T) {
for kind, want := range map[string]string{
domains.KindOperatorRoot: "Hosted",
domains.KindMember: "Hosted",
domains.KindExternal: "Custom",
} {
if label, class := domainKindLabel(kind); label != want || class == "" {
t.Errorf("domainKindLabel(%q) = %q/%q, want %q with a badge class", kind, label, class, want)
}
}
for status, want := range map[string]string{
domains.StatusPending: "Awaiting DNS",
domains.StatusActive: "Active",
domains.StatusExpired: "Expired",
domains.StatusCanceled: "Canceled",
domains.StatusReleased: "Released",
} {
if label, class := domainStatusLabel(status); label != want || class == "" {
t.Errorf("domainStatusLabel(%q) = %q/%q, want %q with a badge class", status, label, class, want)
}
}
}
// TestMemberDomainsNoAddForm pins the dissolution contract
// (dissolve-member-domains): adding an external domain starts only from the
// site-creation flow, so the claims partial renders no add form under ANY
// gate state — while a refusal from the still-routed POST can still explain
// itself.
func TestMemberDomainsNoAddForm(t *testing.T) {
for name, data := range map[string]MemberDomainsData{
"entitled": {CanAddExternal: true},
"ungated": {},
} {
out := renderMemberDomains(t, "member_domains.html", data)
for _, unwanted := range []string{`hx-post="/partials/domains/claims"`, "Add a domain you own", `name="domain"`} {
if strings.Contains(out, unwanted) {
t.Errorf("%s surface must not render the add form (%q)", name, unwanted)
}
}
}
// A refusal still renders: the POST route remains and re-enforces the
// gate, naming it.
denied := renderMemberDomains(t, "member_domains.html", MemberDomainsData{FormError: planGateMessage})
// The apostrophe is HTML-escaped in output, so match the plain tail.
if !strings.Contains(denied, "part of your current plan") {
t.Errorf("denied POST must name the plan gate:\n%s", denied)
}
if strings.Contains(denied, "See its DNS records") {
t.Error("a plan-gate refusal has no claim to link to")
}
}
// TestMemberDomainsClaimsTable covers the list: kind/status badges, the
// placement count, the instructions link for external claims, and the release
// control's guard — offered only for an active claim holding no name.
func TestMemberDomainsClaimsTable(t *testing.T) {
body := renderMemberDomains(t, "member_domains.html", MemberDomainsData{
Claims: []MemberDomainClaimRow{
{
ClaimID: "claim-pending", Root: "example.org",
KindLabel: "Custom", KindClass: "text-bg-primary",
Status: "Awaiting DNS", StatusClass: "text-bg-warning",
External: true, Pending: true,
},
{
ClaimID: "claim-hosted", Root: "site.hosting.test",
KindLabel: "Hosted", KindClass: "text-bg-secondary",
Status: "Active", StatusClass: "text-bg-success",
Placements: 1,
},
{
ClaimID: "claim-empty", Root: "spare.example",
KindLabel: "Custom", KindClass: "text-bg-primary",
Status: "Active", StatusClass: "text-bg-success",
External: true, CanRelease: true,
},
},
})
for _, want := range []string{
"example.org", "Awaiting DNS", "DNS records",
"/partials/domains/claims/claim-pending",
"/partials/domains/claims/claim-empty/release",
"hx-confirm=", "Release",
} {
if !strings.Contains(body, want) {
t.Errorf("claims table missing %q\n%s", want, body)
}
}
// The hosted claim serves a name, so it gets neither a release control nor
// an instructions link.
if strings.Contains(body, "/partials/domains/claims/claim-hosted") {
t.Error("a hosted claim must not offer the external-claim affordances")
}
if strings.Count(body, "/release") != 1 {
t.Errorf("release offered %d times, want exactly the zero-placement claim", strings.Count(body, "/release"))
}
empty := renderMemberDomains(t, "member_domains.html", MemberDomainsData{})
if !strings.Contains(empty, "No domains yet") {
t.Errorf("empty state missing:\n%s", empty)
}
}
// TestMemberDomainsOwnPendingPointer covers the one refusal a member may see
// the reason for: their OWN pending claim at the name is named and linked,
// while any other holder collapses to the registry's single message with
// nothing to follow.
func TestMemberDomainsOwnPendingPointer(t *testing.T) {
mine := renderMemberDomains(t, "member_domains.html", MemberDomainsData{
CanAddExternal: true,
FormDomain: "example.org",
FormError: "You're already verifying that domain.",
FormClaimID: "claim-1",
})
for _, want := range []string{"already verifying", "See its DNS records", "/partials/domains/claims/claim-1"} {
if !strings.Contains(mine, want) {
t.Errorf("own-pending refusal missing %q\n%s", want, mine)
}
}
theirs := renderMemberDomains(t, "member_domains.html", MemberDomainsData{
CanAddExternal: true,
FormError: domains.Availability{Verdict: domains.VerdictTaken}.MemberMessage(),
})
if strings.Contains(theirs, "See its DNS records") {
t.Error("a foreign claim must not be linkable — that would be the oracle we refuse to be")
}
}
// TestMemberDomainsRecordsView covers the DNS instructions: the three-row
// table with the OPTIONAL wildcard row (design D9), copy buttons on every
// name and value, the in-place 5s poll, and the pending-only actions.
func TestMemberDomainsRecordsView(t *testing.T) {
records := newMemberDomainRecords("example.org",
"_member-console-challenge.example.org", "member-console-verify=abc", "connect.example.test")
if len(records) != 3 {
t.Fatalf("records = %d, want 3 (TXT, connect, wildcard)", len(records))
}
if !records[2].Optional || records[2].Name != "*.example.org" || records[2].Value != "connect.example.test" {
t.Errorf("third record = %+v, want the optional wildcard row pointing at the connect target", records[2])
}
if records[0].Optional || records[1].Optional {
t.Error("the TXT and connect rows must not be marked optional")
}
body := renderMemberDomains(t, "member_domains_claim.html", MemberDomainClaimData{
ClaimID: "claim-1",
Domain: "example.org",
Records: records,
StatusText: "Waiting for your DNS records…",
ExpiresAt: "Aug 1, 2026",
Polling: true,
})
for _, want := range []string{
"*.example.org", ">Optional<", "js-copy", `data-copy="connect.example.test"`,
`hx-trigger="every 5s"`, `hx-swap="outerHTML"`,
"/partials/domains/claims/claim-1/check", "/partials/domains/claims/claim-1/cancel",
"Check now", "Cancel verification", "Complete verification by Aug 1, 2026",
} {
if !strings.Contains(body, want) {
t.Errorf("records view missing %q", want)
}
}
// Exactly the two probed records carry a probe badge; the wildcard row
// never reports as an unchecked probe.
if got := strings.Count(body, `text-bg-secondary">Not checked yet<`); got != 2 {
t.Errorf("unchecked probe badges = %d, want exactly the two probed records", got)
}
// Mismatch diagnostics show what the probe actually saw.
records[0].State = domains.ProbeStateMismatch
records[0].Observed = splitProbeObserved("member-console-verify=wrong\nsomething-else")
mismatch := renderMemberDomains(t, "member_domains_claim.html", MemberDomainClaimData{
ClaimID: "claim-1", Domain: "example.org", Records: records, Polling: true,
LastChecked: "Jul 24, 2026 10:00 UTC",
})
for _, want := range []string{"We saw:", "member-console-verify=wrong", "something-else", "Last checked Jul 24, 2026 10:00 UTC"} {
if !strings.Contains(mismatch, want) {
t.Errorf("mismatch view missing %q", want)
}
}
}
// TestMemberDomainsTerminalViews covers the branches that stop polling:
// verified (which creates nothing — design D4 — so it points at the service
// card instead), placed, canceled, expired, and a hosted claim that has no
// records to publish at all.
func TestMemberDomainsTerminalViews(t *testing.T) {
base := MemberDomainClaimData{ClaimID: "claim-1", Domain: "example.org"}
verified := base
verified.Verified = true
out := renderMemberDomains(t, "member_domains_claim.html", verified)
for _, want := range []string{"is verified ✓", "service card"} {
if !strings.Contains(out, want) {
t.Errorf("verified view missing %q\n%s", want, out)
}
}
for _, unwanted := range []string{"Check now", "Cancel verification", `hx-trigger="every 5s"`} {
if strings.Contains(out, unwanted) {
t.Errorf("verified view must not keep %q", unwanted)
}
}
placed := verified
placed.Placed = true
if out := renderMemberDomains(t, "member_domains_claim.html", placed); !strings.Contains(out, "verified ✓ and in use") {
t.Errorf("placed view missing its copy:\n%s", out)
}
canceled := base
canceled.Canceled = true
if out := renderMemberDomains(t, "member_domains_claim.html", canceled); !strings.Contains(out, "was canceled") {
t.Errorf("canceled view missing its copy:\n%s", out)
}
expired := base
expired.Expired = true
if out := renderMemberDomains(t, "member_domains_claim.html", expired); !strings.Contains(out, "expired") {
t.Errorf("expired view missing its copy:\n%s", out)
}
hosted := base
hosted.Hosted = true
hosted.Domain = "site.hosting.test"
out = renderMemberDomains(t, "member_domains_claim.html", hosted)
if !strings.Contains(out, "part of our hosting") {
t.Errorf("hosted view missing its copy:\n%s", out)
}
if strings.Contains(out, "js-copy") {
t.Error("a hosted claim has no records to copy")
}
}
// TestClaimErrorMessageRendersBudgetHistory pins the one refusal this surface
// does NOT collapse (design D3). The ledger and initiation budgets report the
// caller's own history and retry time — facts about no other workspace — and
// they are the only refusals a member can act on. Everything else keeps
// collapsing to the single indistinguishable message.
func TestClaimErrorMessageRendersBudgetHistory(t *testing.T) {
h := &MemberDomainsHandler{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
retry := time.Date(2026, 8, 1, 15, 4, 0, 0, time.UTC)
ledger := &domains.BudgetExceeded{
Reason: domains.ErrAbandonBudget,
Scope: "example.org",
Count: 3,
RetryAt: retry,
}
msg := h.claimErrorMessage(ledger)
for _, want := range []string{"example.org", "3", retry.Format("Jan 2, 2006 15:04 MST")} {
if !strings.Contains(msg, want) {
t.Errorf("ledger message %q omits %q", msg, want)
}
}
breadth := &domains.BudgetExceeded{
Reason: domains.ErrInitiationBudget,
Count: 10,
RetryAt: retry,
}
if msg := h.claimErrorMessage(breadth); !strings.Contains(msg, retry.Format("Jan 2, 2006 15:04 MST")) {
t.Errorf("initiation message %q omits the retry time", msg)
}
// Every other refusal still collapses: a taken name must not become an
// oracle for who holds it.
taken := &domains.Unavailable{Name: "example.org", Verdict: domains.VerdictTaken, Detail: "another workspace holds it"}
if msg := h.claimErrorMessage(taken); msg != taken.MemberMessage() || strings.Contains(msg, "another workspace") {
t.Errorf("taken-name message = %q, want the collapsed refusal", msg)
}
}