Files
member-console/internal/server/member_domains_test.go
T
cgalo5758 88db730fcc Add dual licensing and SPDX headers
Introduce a commercial license option alongside AGPL-3.0-only, require a
CLA for contributors, and document the terms in COMMERCIAL.md and
NOTICE. Add a script to stamp SPDX headers on Go files and apply it
across the tree.
2026-09-06 02:29:42 -05:00

287 lines
11 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
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,
"helpIcon": helpIcon,
}).ParseFS(partialsSub, "member_*.html")
if err != nil {
t.Fatalf("ParseFS: %v", err)
}
tmpl = template.Must(web.ParseUIPartials(tmpl))
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 b := domainKindBadge(kind); b.Label != want || b.Tone == "" {
t.Errorf("domainKindBadge(%q) = %q/%q, want %q with a tone", kind, b.Label, b.Tone, 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 b := domainStatusBadge(status); b.Label != want || b.Tone == "" {
t.Errorf("domainStatusBadge(%q) = %q/%q, want %q with a tone", status, b.Label, b.Tone, 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. The route this once posted to is gone outright (finding
// FA-42: the POST had no caller anywhere in the console), not merely
// unrendered, so there is no refusal path left to pin here either.
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)
}
}
}
}
// 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",
KindBadge: domainKindBadge(domains.KindExternal),
StatusBadge: domainStatusBadge(domains.StatusPending),
External: true, Pending: true,
},
{
ClaimID: "claim-hosted", Root: "site.hosting.test",
KindBadge: domainKindBadge(domains.KindMember),
StatusBadge: domainStatusBadge(domains.StatusActive),
Placements: 1,
},
{
ClaimID: "claim-empty", Root: "spare.example",
KindBadge: domainKindBadge(domains.KindExternal),
StatusBadge: domainStatusBadge(domains.StatusActive),
External: true, CanRelease: true,
},
},
})
for _, want := range []string{
"example.org", "Awaiting DNS", "DNS records",
"/partials/domains/claims/claim-pending",
`data-action-url="/partials/domains/claims/claim-empty/release"`,
`data-action-target="closest .domains-surface"`, "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)
}
}
// 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)
}
}