- Restructure operator sidebar into a flat task list with indented children; fold plan topology into plan ladders - Expand member catalog non-plan section to all published non-tier products; require recurring Stripe-mapped prices for purchase - Add operator domains placements and terminal-claims ledger; redirect /domains to the FedWiki Sites Domains anchor - Apply canonical vocabulary and chrome/form conventions; migrate seeded FedWiki Sites display name
238 lines
9.7 KiB
Go
238 lines
9.7 KiB
Go
// 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)
|
|
}
|
|
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; placed
|
|
// claims read "in use"; operator roots read "operator root" even when they
|
|
// hold placements (roots always do — every hosted site places under one).
|
|
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: "operator_root",
|
|
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 should be labeled 'in use'")
|
|
}
|
|
if !strings.Contains(body, ">operator root<") {
|
|
t.Error("operator root should keep its label even when it holds placements")
|
|
}
|
|
if got := strings.Count(body, "Force-release {{"); got != 0 {
|
|
t.Errorf("unexecuted template fragments in output: %d", got)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
// 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: ""},
|
|
},
|
|
})
|
|
|
|
if !strings.Contains(body, "Claim history (4)") {
|
|
t.Error("claim history summary should count the terminal claims")
|
|
}
|
|
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, "Claim history (0)") {
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|