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.
318 lines
12 KiB
Go
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)
|
|
}
|
|
}
|