// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo // DB-free render coverage for the operator Domains moderation list's // force-release affordance: offered exactly where the registry would admit // it. A rendered control the server always refuses is the dead-end-affordance // failure mode (issues.md 2026-07-26 — the domains walkthrough timed out on // a placed claim's button). // // Also covers ux-ia-naming tasks 9.1-9.3: the placements section, the // terminal-claims ledger section, and the name-policy / abandonment-scope // copy, all of which render from static OperatorDomainsData fixtures here // rather than a live database. package server import ( "database/sql" "html/template" "io/fs" "log/slog" "net/http/httptest" "strings" "testing" "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" ) func renderOperatorDomains(t *testing.T, data OperatorDomainsData) string { t.Helper() sub, err := fs.Sub(embeds.Templates, "templates/partials") if err != nil { t.Fatalf("sub templates FS: %v", err) } tmpl, err := template.New("operator-domains").Funcs(template.FuncMap{ "routeURL": web.RouteURL, }).ParseFS(sub, "operator_domains.html") if err != nil { t.Fatalf("parse operator_domains.html: %v", err) } tmpl = template.Must(web.ParseUIPartials(tmpl)) rec := httptest.NewRecorder() NewSafeTemplates(tmpl, slog.Default()).Render(rec, "operator_domains.html", data) if rec.Code != 200 { t.Fatalf("render = %d: %s", rec.Code, rec.Body.String()) } return rec.Body.String() } // TestOperatorDomainsForceReleaseOnlyWhereAdmissible pins the affordance to // the registry's own guards: unplaced non-root claims get the button; a // placed claim's Actions cell renders nothing (the Placements column carries // the count that says why; the "in use" note left with record-table-actions, // chrome-conventions "A verb-less row leaves its Actions cell empty"); an // operator root's Actions cell renders nothing even when it holds placements // (roots always do — every hosted site places under one) — the Kind column // already carries that fact, so the cell does not repeat it as filler text // (maintainer, sheet pass: "Domains: same" as Grants' empty Actions cells, // design D15). func TestOperatorDomainsForceReleaseOnlyWhereAdmissible(t *testing.T) { body := renderOperatorDomains(t, OperatorDomainsData{ Claims: []OperatorDomainClaimRow{ {ClaimID: "claim-free", Root: "spare.example.org", Kind: "external", Status: "active", Releasable: true}, {ClaimID: "claim-used", Root: "busy.example.org", Kind: "external", Status: "active", Placements: 2, Placed: true}, {ClaimID: "claim-root", Root: "hosting.test", Kind: domains.KindOperatorRoot, Status: "active", Placements: 5}, }, }) if !strings.Contains(body, "/partials/operator/domains/claim-free/force-release") { t.Error("unplaced non-root claim must offer force-release") } if strings.Contains(body, "/partials/operator/domains/claim-used/force-release") { t.Error("placed claim must not offer force-release — the registry's placement guard refuses it") } if strings.Contains(body, "/partials/operator/domains/claim-root/force-release") { t.Error("operator root must not offer force-release") } if strings.Contains(body, ">in use<") { t.Error("placed claim's Actions cell must render nothing; the Placements column carries the reason") } // The Kind column still says "Operator root" (the badge); the Actions // cell's old lowercase filler text is gone. if !strings.Contains(body, `Operator root`) { t.Error("operator root's Kind column should still carry the Operator root badge") } if strings.Contains(body, ">operator root<") { t.Error("operator root's Actions cell must render nothing, not filler text") } rootRowStart := strings.Index(body, "hosting.test") if rootRowStart == -1 { t.Fatal("operator root row not found") } rootRowEnd := strings.Index(body[rootRowStart:], "") if rootRowEnd == -1 { t.Fatal("operator root row has no closing ") } rootRow := body[rootRowStart : rootRowStart+rootRowEnd] if strings.Contains(rootRow, "btn") { t.Errorf("operator root's Actions cell must render no button, got row:\n%s", rootRow) } if got := strings.Count(body, "Force-release {{"); got != 0 { t.Errorf("unexecuted template fragments in output: %d", got) } // The live-claims table drops its Evidence column (kept in claim // history only, design D15). if strings.Contains(body, "Evidence") { t.Error("the live-claims table must not carry an Evidence column") } } // TestOperatorDomainsScopeCopyPresent pins task 9.3's copy: the name-policy // panel states its own boundary (carving inside operator roots only) and // where external claims are governed instead, and every abandonment tally is // labeled "abandonment scope" — never bare "scope" — with the count-wide / // clear-narrow width rule stated inline (invariant 10, the "single most // damaging misreading" the model card calls out). func TestOperatorDomainsScopeCopyPresent(t *testing.T) { body := renderOperatorDomains(t, OperatorDomainsData{ Policy: OperatorDomainPolicyView{ ClaimWindow: "1d", PendingCap: "5", AbandonBudget: "3", AbandonWindow: "7d", ScopeLabels: "2", InitiationBudget: "10", }, }) for _, want := range []string{ "Name policy governs carving inside operator roots only", "External claims are governed separately, by", "verification and the abuse apparatus", "Abandonment scope budget", "Abandonment scope width", "Abandonments count against the whole scope root and everything beneath it", "Verifying a name clears only that name's own subtree", } { if !strings.Contains(body, want) { t.Errorf("operator Domains page does not say %q", want) } } if em := strings.Count(body, ">Scope labels<") + strings.Count(body, ">Abandonments per scope<"); em != 0 { t.Error("policy card still uses the old bare-scope field labels") } } // TestOperatorDomainsPlacementsSectionRenders pins task 9.1: the placements // section lists the exact names actually being served, each with the // provider resource it is wired to and its servable state — deliberately a // different table from the claims list above it (a claim can hold a name // that serves nothing at all). func TestOperatorDomainsPlacementsSectionRenders(t *testing.T) { body := renderOperatorDomains(t, OperatorDomainsData{ Placements: []OperatorPlacementRow{ {Name: "wiki.example.org", Provider: "fedwiki", ResourceRef: "site-abc123", Servable: true}, {Name: "archived.example.org", Provider: "fedwiki", ResourceRef: "site-def456", Servable: false}, }, }) for _, want := range []string{ "Placements", "wiki.example.org", "fedwiki", "site-abc123", "archived.example.org", "site-def456", } { if !strings.Contains(body, want) { t.Errorf("placements section does not mention %q", want) } } if !strings.Contains(body, ">Servable<") { t.Error("servable placement should be labeled 'Servable'") } if !strings.Contains(body, ">Unservable<") { t.Error("unservable placement should be labeled 'Unservable'") } empty := renderOperatorDomains(t, OperatorDomainsData{}) if !strings.Contains(empty, "No placements") { t.Error("empty placements list should say so, not render a broken table") } if !strings.Contains(empty, "A placement is a site or service served at a claimed name.") { t.Error("the empty placements state should define what a placement is (design D15)") } } // TestOperatorDomainsTerminalClaimsSectionRenders pins task 9.2: expired, // canceled, and released claims are reachable in a segregated section (kept // out of the live-claims table so live claims stay prominent), each terminal // external claim carrying an Evidence column and an Outcome column derived // from the ledger, never from Status. func TestOperatorDomainsTerminalClaimsSectionRenders(t *testing.T) { body := renderOperatorDomains(t, OperatorDomainsData{ TerminalClaims: []OperatorTerminalClaimRow{ {ClaimID: "c-abandoned", Root: "squatted.example.org", Kind: "external", Status: "expired", OrgName: "Acme Co", Evidence: false, Outcome: "abandoned"}, {ClaimID: "c-system", Root: "outage.example.org", Kind: "external", Status: "canceled", OrgName: "Acme Co", Evidence: false, Outcome: "rolled back by the system"}, {ClaimID: "c-walked", Root: "proven.example.org", Kind: "external", Status: "released", OrgName: "Acme Co", Evidence: true, Outcome: "walked away with evidence published"}, {ClaimID: "c-member", Root: "carved.hosting.test", Kind: "member", Status: "released", OrgName: "Acme Co", Evidence: false, Outcome: ""}, }, }) // task 10, round 2: the History section is an ordinary sectionHeader // now, not a
/sectionSummary disclosure, so its count reads // as a plain noun phrase, not a parenthesised digit. if !strings.Contains(body, "4 terminal claims") { t.Error("claim history summary should count the terminal claims") } if !strings.Contains(body, "Evidence") { t.Error("claim history should keep its Evidence column") } for _, want := range []string{ "squatted.example.org", "abandoned", "outage.example.org", "rolled back by the system", "proven.example.org", "walked away with evidence published", "carved.hosting.test", } { if !strings.Contains(body, want) { t.Errorf("claim history does not mention %q", want) } } // "canceled" must never itself read as blame — the three phrases above // are the ones that carry blame, and the bare status badge is not one of // them (dimension "Status vs blame"). if strings.Contains(body, "canceled by") { t.Error("status must not be dressed up as blame in the ledger table") } empty := renderOperatorDomains(t, OperatorDomainsData{}) if !strings.Contains(empty, "No expired, canceled, or released claims yet") { t.Error("empty terminal-claims list should say so, not render a broken table") } if !strings.Contains(empty, "0 terminal claims") { t.Error("claim history summary should read 0 when there is none") } } // TestDomainTerminalOutcome unit-tests the derivation directly (task 9.2): // blame is read from the ledger columns in a fixed priority // (system_canceled_at, then abandoned_at, then evidence_at), never from // status, and only for external claims — the ledger governs external claims // only (dimension "Name policy vs abuse policy"). func TestDomainTerminalOutcome(t *testing.T) { valid := sql.NullTime{Valid: true} none := sql.NullTime{} cases := []struct { name string kind string evidenceAt, abandonedAt, systemCanceledAt sql.NullTime want string }{ {"system rollback wins even with other columns unset", domains.KindExternal, none, none, valid, "rolled back by the system"}, {"abandoned, no evidence", domains.KindExternal, none, valid, none, "abandoned"}, {"force-cancel abandons even over evidence", domains.KindExternal, valid, valid, none, "abandoned"}, {"evidence published, no abandonment", domains.KindExternal, valid, none, none, "walked away with evidence published"}, {"ledger untouched", domains.KindExternal, none, none, none, "—"}, {"member claim carries no outcome", domains.KindMember, valid, valid, valid, ""}, {"operator root carries no outcome", domains.KindOperatorRoot, none, none, none, ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := domainTerminalOutcome(tc.kind, tc.evidenceAt, tc.abandonedAt, tc.systemCanceledAt) if got != tc.want { t.Errorf("domainTerminalOutcome(%s, ...) = %q, want %q", tc.kind, got, tc.want) } }) } }