Spread the Rules table's treatment to every table that carries verbs (record-table-actions, archived 2026-09-19): the table is align-middle, the Actions header and cells are text-end, every button in an Actions cell carries ms-1, and the header's word is for assistive technology only (a visually-hidden span; Primer: row actions do not require a visible column header). The two integrations tables and the member domains list, which headed their controls with an empty cell, take the hidden word too. A new anatomy-lint rule, actions-column, refuses a table that drifts from any of it. Decisions on the way: a row tint marks the row in play (the current workspace, a staged change), never a record's status; the member catalog lists only what can be bought, so a Listed product without an active, synced, recurring price is left out instead of shown with "Not available for purchase"; two Actions cells that carried text in a verb's place are empty (the Placements column and the pending panel already say why); and four record tables gain their width floor. Specs: page-anatomy "Tables share one density" modified, ui-quality-gate gains "Lint refuses an Actions column without its treatment", member-product-discovery's Extras bucket and truthful-copy requirements modified. docs/design-system.md §6 states the treatment and its reasons.
279 lines
12 KiB
Go
279 lines
12 KiB
Go
// 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, `<span class="badge text-bg-light border">Operator root</span>`) {
|
|
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:], "</tr>")
|
|
if rootRowEnd == -1 {
|
|
t.Fatal("operator root row has no closing </tr>")
|
|
}
|
|
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, "<th>Evidence</th>") {
|
|
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 <details>/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, "<th>Evidence</th>") {
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|