Archives openspec change slice3-walk-fixes and syncs its five delta specs (fedwiki-sites, entitlements, operator-panel-navigation, operator-list-scale, ui-quality-gate). - FedWiki site usage is read from active site rows in both quota readers; the reservation counter converges on the rows: raise-only after farm sync and inside the create quota check, exact at boot. The understated production counters repair on the first boot. - The People tile caption excludes the reserved system person through the same query parameter the directory uses. - The operator Domains live-claims list is a governed list: pages of 50, true total, search over root name and organization, a pending/active facet. - New lint rule table-without-list-controls refuses an unpaged page-body table unless it carries a list-scale exempt marker with a reason; six curated or detail tables carry one. Its first run caught the operator FedWiki sites list, which is now governed the same way. - Entitlement-set rule copy: "Per unit", "Multiplied by the quantity purchased or granted."
620 lines
25 KiB
Go
620 lines
25 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||
|
||
package server
|
||
|
||
// Operator Domains surface (claim-lifecycle-hardening design D7): the page is
|
||
// the only cross-workspace view of the namespace and the only place the claim
|
||
// policy is visible, and force-release is the only write in the console that
|
||
// touches a claim its caller does not own. Both are therefore tested from the
|
||
// route in, with the role guard in place — a moderation tool that any member
|
||
// could reach would be worse than no moderation tool.
|
||
//
|
||
// DB-backed via TEST_DATABASE_URL; the fixtures are the rollback suite's
|
||
// (newRollbackTestDB / newRollbackWorkspace) since both need the same core +
|
||
// domains streams.
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/alexedwards/scs/v2"
|
||
|
||
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
|
||
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
|
||
)
|
||
|
||
// uniqueDomainsTestName returns a fresh two-label name, so every test
|
||
// allocates in its own corner of the namespace the whole suite shares.
|
||
func uniqueDomainsTestName() string {
|
||
return uniqueDomainsTestToken() + ".example"
|
||
}
|
||
|
||
// uniqueDomainsTestToken returns the label every name a single test
|
||
// allocates can share. The suite runs against a database other tests are
|
||
// writing to, so the live-claims list is never empty and its total is never
|
||
// this test's alone: a test that needs a deterministic total searches for
|
||
// its own token instead of reading the unfiltered list
|
||
// (operator-list-scale: the stated total is the total for the current
|
||
// search).
|
||
func uniqueDomainsTestToken() string {
|
||
return fmt.Sprintf("m%d", time.Now().UnixNano())
|
||
}
|
||
|
||
// seedLiveClaims inserts n external claims at <token>-<i>.example under one
|
||
// workspace, each a minute older than the one before so the list's
|
||
// newest-first ordering is deterministic: index 0 is the newest and lands
|
||
// first on page one. It writes the rows directly rather than going through
|
||
// ClaimExternal because the registry's pending cap and daily initiation
|
||
// budget refuse a workspace this many claims, and what is under test is the
|
||
// list at scale, not the allocation policy.
|
||
func seedLiveClaims(t *testing.T, database *sql.DB, workspaceID, token string, n int, status string) []string {
|
||
t.Helper()
|
||
names := make([]string, n)
|
||
for i := range n {
|
||
names[i] = fmt.Sprintf("%s-%03d.example", token, i)
|
||
reversed := fmt.Sprintf("example.%s-%03d", token, i)
|
||
if _, err := database.ExecContext(context.Background(),
|
||
`INSERT INTO domains.claims (workspace_id, root_fqdn, reversed_labels, kind, status, created_at)
|
||
VALUES ($1, $2, $3, 'external', $4, now() - make_interval(mins => $5::int))`,
|
||
workspaceID, names[i], reversed, status, i); err != nil {
|
||
t.Fatalf("seed claim %s: %v", names[i], err)
|
||
}
|
||
}
|
||
return names
|
||
}
|
||
|
||
// liveSectionOf is the rendered page above the Claim history header: the
|
||
// governed live-claims list and its controls. The history section below
|
||
// carries the same names once a claim goes terminal, so every assertion
|
||
// about what the live list shows scopes to this slice. The boundary is the
|
||
// history header rather than the placements one because "Placements" is also
|
||
// a column heading in the live table itself.
|
||
func liveSectionOf(body string) string {
|
||
if i := strings.Index(body, "Claim history"); i >= 0 {
|
||
return body[:i]
|
||
}
|
||
return body
|
||
}
|
||
|
||
// domainsModerationEnv is the surface under test: the handler with its routes
|
||
// registered, plus one authenticated operator session and one plain member
|
||
// session to drive them with.
|
||
type domainsModerationEnv struct {
|
||
t *testing.T
|
||
database *sql.DB
|
||
mux *http.ServeMux
|
||
registry *domains.Registry
|
||
operator context.Context
|
||
member context.Context
|
||
}
|
||
|
||
func newDomainsModerationEnv(t *testing.T) *domainsModerationEnv {
|
||
t.Helper()
|
||
database := newRollbackTestDB(t)
|
||
sm := scs.New()
|
||
authCfg := &auth.Config{SessionManager: sm}
|
||
registry := domains.NewRegistry(database)
|
||
handler, err := NewOperatorPartialsHandler(OperatorPartialsConfig{
|
||
Database: database,
|
||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||
AuthConfig: authCfg,
|
||
Registry: registry,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewOperatorPartialsHandler: %v", err)
|
||
}
|
||
mux := http.NewServeMux()
|
||
handler.RegisterRoutes(mux)
|
||
|
||
session := func(roles ...string) context.Context {
|
||
ctx, err := sm.Load(context.Background(), "")
|
||
if err != nil {
|
||
t.Fatalf("session Load: %v", err)
|
||
}
|
||
sm.Put(ctx, "authenticated", true)
|
||
sm.Put(ctx, "roles", roles)
|
||
return ctx
|
||
}
|
||
return &domainsModerationEnv{
|
||
t: t,
|
||
database: database,
|
||
mux: mux,
|
||
registry: registry,
|
||
operator: session(OperatorRole),
|
||
member: session("member"),
|
||
}
|
||
}
|
||
|
||
func (e *domainsModerationEnv) do(ctx context.Context, method, target string) (int, string) {
|
||
e.t.Helper()
|
||
req := httptest.NewRequestWithContext(ctx, method, target, nil)
|
||
rec := httptest.NewRecorder()
|
||
e.mux.ServeHTTP(rec, req)
|
||
return rec.Code, rec.Body.String()
|
||
}
|
||
|
||
// orgNameOf resolves the organization a workspace belongs to, which is what
|
||
// the page prints as the holder.
|
||
func (e *domainsModerationEnv) orgNameOf(workspaceID string) string {
|
||
e.t.Helper()
|
||
var name string
|
||
if err := e.database.QueryRowContext(context.Background(),
|
||
`SELECT o.name FROM core.workspaces w
|
||
JOIN core.organizations o ON o.org_id = w.org_id
|
||
WHERE w.workspace_id = $1`, workspaceID).Scan(&name); err != nil {
|
||
e.t.Fatalf("read org name for %s: %v", workspaceID, err)
|
||
}
|
||
return name
|
||
}
|
||
|
||
func (e *domainsModerationEnv) claimState(claimID string) (string, sql.NullTime) {
|
||
e.t.Helper()
|
||
var status string
|
||
var abandoned sql.NullTime
|
||
if err := e.database.QueryRowContext(context.Background(),
|
||
`SELECT status, abandoned_at FROM domains.claims WHERE claim_id = $1`, claimID).Scan(&status, &abandoned); err != nil {
|
||
e.t.Fatalf("read claim %s: %v", claimID, err)
|
||
}
|
||
return status, abandoned
|
||
}
|
||
|
||
// TestOperatorDomainsSurfaceIsOperatorOnly: both halves are behind the role,
|
||
// and the refused mutation must leave the claim exactly as it was.
|
||
func TestOperatorDomainsSurfaceIsOperatorOnly(t *testing.T) {
|
||
env := newDomainsModerationEnv(t)
|
||
holder := newRollbackWorkspace(t, env.database)
|
||
claim, err := env.registry.ClaimExternal(context.Background(), holder, uniqueDomainsTestName())
|
||
if err != nil {
|
||
t.Fatalf("claim: %v", err)
|
||
}
|
||
action := "/partials/operator/domains/" + claim.ClaimID + "/force-release"
|
||
|
||
if code, _ := env.do(env.member, http.MethodGet, "/operator/domains"); code != http.StatusForbidden {
|
||
t.Errorf("member GET /operator/domains = %d, want %d", code, http.StatusForbidden)
|
||
}
|
||
if code, _ := env.do(env.member, http.MethodPost, action); code != http.StatusForbidden {
|
||
t.Errorf("member force-release = %d, want %d", code, http.StatusForbidden)
|
||
}
|
||
if status, _ := env.claimState(claim.ClaimID); status != domains.StatusPending {
|
||
t.Errorf("refused member force-release left the claim %s, want it untouched at %s", status, domains.StatusPending)
|
||
}
|
||
|
||
if code, _ := env.do(env.operator, http.MethodGet, "/operator/domains"); code != http.StatusOK {
|
||
t.Errorf("operator GET /operator/domains = %d, want %d", code, http.StatusOK)
|
||
}
|
||
}
|
||
|
||
// TestOperatorDomainsPageListsEveryWorkspaceAndThePolicy: the page answers
|
||
// "who holds what" across the deployment and prints the effective policy,
|
||
// which no other surface shows at all.
|
||
//
|
||
// Page-aware since the live list became governed (operator-list-scale): the
|
||
// two claims are asked for by the token they share, because an unfiltered
|
||
// page one holds fifty rows out of however many the shared database carries
|
||
// and need not hold either of them.
|
||
func TestOperatorDomainsPageListsEveryWorkspaceAndThePolicy(t *testing.T) {
|
||
env := newDomainsModerationEnv(t)
|
||
ctx := context.Background()
|
||
alice, bob := newRollbackWorkspace(t, env.database), newRollbackWorkspace(t, env.database)
|
||
token := uniqueDomainsTestToken()
|
||
|
||
waiting, err := env.registry.ClaimExternal(ctx, alice, token+"-a.example")
|
||
if err != nil {
|
||
t.Fatalf("claim: %v", err)
|
||
}
|
||
if _, err := domains.New(env.database).RecordClaimProbe(ctx, domains.RecordClaimProbeParams{
|
||
ClaimID: waiting.ClaimID,
|
||
TxtState: domains.ProbeStateMismatch,
|
||
ConnectState: domains.ProbeStateMissing,
|
||
Evidence: true,
|
||
}); err != nil {
|
||
t.Fatalf("record probe: %v", err)
|
||
}
|
||
other, err := env.registry.ClaimExternal(ctx, bob, token+"-b.example")
|
||
if err != nil {
|
||
t.Fatalf("claim: %v", err)
|
||
}
|
||
|
||
code, body := env.do(env.operator, http.MethodGet, "/operator/domains?q="+token)
|
||
if code != http.StatusOK {
|
||
t.Fatalf("GET /operator/domains = %d, want %d", code, http.StatusOK)
|
||
}
|
||
for _, want := range []string{
|
||
waiting.RootFqdn, other.RootFqdn,
|
||
env.orgNameOf(alice), env.orgNameOf(bob),
|
||
"Effective claim policy",
|
||
`href="/operator/domains"`, // the sidebar entry
|
||
"Showing 1–2 of 2", // the true total for this search
|
||
"2 live claims", // and the header count reads the same
|
||
} {
|
||
if !strings.Contains(body, want) {
|
||
t.Errorf("operator Domains page does not mention %q", want)
|
||
}
|
||
}
|
||
// The effective policy is the registry's resolved set, not a config echo.
|
||
policy := env.registry.Policy()
|
||
for _, want := range []string{
|
||
formatPolicyWindow(policy.ClaimWindow),
|
||
formatPolicyWindow(policy.AbandonWindow),
|
||
formatPolicyBudget(policy.AbandonBudget),
|
||
} {
|
||
if !strings.Contains(body, ">"+want+"<") {
|
||
t.Errorf("policy card does not print %q", want)
|
||
}
|
||
}
|
||
// Evidence left the live-claims table for claim history only (design
|
||
// D15, maintainer sheet pass, 2026-09-01): a live claim's zone-control
|
||
// observation is not printed on this page until the claim goes
|
||
// terminal, so no "Seen" badge is expected here.
|
||
// Claim history (the trailing section, task 10 round 2: an ordinary
|
||
// section, not a disclosure) keeps its Evidence column, so the check
|
||
// scopes to the page above the History section header.
|
||
liveSection := body
|
||
if i := strings.Index(body, "Claim history"); i >= 0 {
|
||
liveSection = body[:i]
|
||
}
|
||
if strings.Contains(liveSection, ">Seen<") || strings.Contains(liveSection, "<th>Evidence</th>") {
|
||
t.Error("the live-claims table must not carry an Evidence column or badge")
|
||
}
|
||
}
|
||
|
||
// TestForceReleaseFreesTheNameAndRecordsIt: the moderation happy path, from
|
||
// the route, including the toast the operator reads and the ledger entry the
|
||
// holder is charged.
|
||
func TestForceReleaseFreesTheNameAndRecordsIt(t *testing.T) {
|
||
env := newDomainsModerationEnv(t)
|
||
ctx := context.Background()
|
||
holder, next := newRollbackWorkspace(t, env.database), newRollbackWorkspace(t, env.database)
|
||
name := uniqueDomainsTestName()
|
||
|
||
claim, err := env.registry.ClaimExternal(ctx, holder, name)
|
||
if err != nil {
|
||
t.Fatalf("claim: %v", err)
|
||
}
|
||
|
||
req := httptest.NewRequestWithContext(env.operator, http.MethodPost,
|
||
"/partials/operator/domains/"+claim.ClaimID+"/force-release", nil)
|
||
rec := httptest.NewRecorder()
|
||
env.mux.ServeHTTP(rec, req)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("force-release = %d, want %d", rec.Code, http.StatusOK)
|
||
}
|
||
if trigger := rec.Header().Get("HX-Trigger"); !strings.Contains(trigger, "showSuccessToast") || !strings.Contains(trigger, name) {
|
||
t.Errorf("HX-Trigger = %q, want a success toast naming %s", trigger, name)
|
||
}
|
||
// The claim is now terminal (canceled), so task 9.2 makes it reachable in
|
||
// the claim-history section deliberately -- what must stay true is that
|
||
// it left the LIVE table, not that it vanished from the page entirely.
|
||
body := rec.Body.String()
|
||
if live := body[:strings.Index(body, "Claim history")]; strings.Contains(live, name) {
|
||
t.Error("the re-rendered live table still shows the force-released claim")
|
||
}
|
||
|
||
status, abandoned := env.claimState(claim.ClaimID)
|
||
if status != domains.StatusCanceled {
|
||
t.Errorf("force-released pending claim = %s, want %s", status, domains.StatusCanceled)
|
||
}
|
||
if !abandoned.Valid {
|
||
t.Error("force-release recorded no abandonment for the holder")
|
||
}
|
||
if _, err := env.registry.ClaimExternal(ctx, next, name); err != nil {
|
||
t.Errorf("the freed name is not claimable: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestForceReleaseRefusalsKeepTheClaim: the guards members face are the guards
|
||
// operators face. Each refusal answers a 4xx with the list re-rendered, so the
|
||
// operator sees why (docs/operator-ux-conventions.md §8).
|
||
func TestForceReleaseRefusalsKeepTheClaim(t *testing.T) {
|
||
env := newDomainsModerationEnv(t)
|
||
ctx := context.Background()
|
||
holder := newRollbackWorkspace(t, env.database)
|
||
|
||
// A claim with a placement on it.
|
||
placed, err := env.registry.ClaimExternal(ctx, holder, uniqueDomainsTestName())
|
||
if err != nil {
|
||
t.Fatalf("claim: %v", err)
|
||
}
|
||
if rows, err := domains.New(env.database).MarkClaimActive(ctx, placed.ClaimID); err != nil || rows != 1 {
|
||
t.Fatalf("activate claim = (%d, %v)", rows, err)
|
||
}
|
||
if _, err := env.database.ExecContext(ctx,
|
||
`INSERT INTO core.providers (provider, provider_kind, display_name)
|
||
VALUES ('testprovider', 'provisioning', 'Test Provider')
|
||
ON CONFLICT (provider) DO NOTHING`); err != nil {
|
||
t.Fatalf("fixture provider: %v", err)
|
||
}
|
||
if _, err := env.registry.Place(ctx, domains.PlaceParams{
|
||
WorkspaceID: holder, FQDN: placed.RootFqdn, Provider: "testprovider",
|
||
ResourceRef: "res-" + placed.ClaimID, Servable: true,
|
||
}); err != nil {
|
||
t.Fatalf("place: %v", err)
|
||
}
|
||
|
||
// An operator root, which is ensured from configuration at every boot.
|
||
root, err := env.registry.EnsureOperatorRoot(ctx, holder, uniqueDomainsTestName())
|
||
if err != nil {
|
||
t.Fatalf("ensure operator root: %v", err)
|
||
}
|
||
|
||
cases := []struct {
|
||
name string
|
||
claimID string
|
||
want string
|
||
}{
|
||
{"placement guard", placed.ClaimID, "still has placements"},
|
||
{"operator root", root.ClaimID, "Operator roots"},
|
||
{"unknown claim", "00000000-0000-7000-8000-000000000000", "no longer exists"},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
code, body := env.do(env.operator, http.MethodPost,
|
||
"/partials/operator/domains/"+tc.claimID+"/force-release")
|
||
if code == http.StatusOK {
|
||
t.Fatalf("refused force-release answered %d; error-handler.js keys off the status", code)
|
||
}
|
||
if !strings.Contains(body, tc.want) {
|
||
t.Errorf("body does not explain the refusal (want %q)", tc.want)
|
||
}
|
||
})
|
||
}
|
||
|
||
if status, _ := env.claimState(placed.ClaimID); status != domains.StatusActive {
|
||
t.Errorf("placed claim = %s, want it untouched at %s", status, domains.StatusActive)
|
||
}
|
||
if status, _ := env.claimState(root.ClaimID); status != domains.StatusActive {
|
||
t.Errorf("operator root = %s, want it untouched at %s", status, domains.StatusActive)
|
||
}
|
||
}
|
||
|
||
// TestOperatorDomainsPlacementsSectionListsWhatActuallyServes exercises task
|
||
// 9.1 end-to-end: place a claim through the real registry, and confirm the
|
||
// name, provider, resource, and servable state reach the rendered page. A
|
||
// second, unplaced claim proves the placements section is genuinely reading
|
||
// domains.placements and not just echoing the claims list -- it must NOT
|
||
// appear there (docs/models/domains-registry.md, "Claim vs placement": a
|
||
// claim with no placements serves zero names).
|
||
func TestOperatorDomainsPlacementsSectionListsWhatActuallyServes(t *testing.T) {
|
||
env := newDomainsModerationEnv(t)
|
||
ctx := context.Background()
|
||
holder := newRollbackWorkspace(t, env.database)
|
||
|
||
placed, err := env.registry.ClaimExternal(ctx, holder, uniqueDomainsTestName())
|
||
if err != nil {
|
||
t.Fatalf("claim: %v", err)
|
||
}
|
||
if rows, err := domains.New(env.database).MarkClaimActive(ctx, placed.ClaimID); err != nil || rows != 1 {
|
||
t.Fatalf("activate claim = (%d, %v)", rows, err)
|
||
}
|
||
if _, err := env.database.ExecContext(ctx,
|
||
`INSERT INTO core.providers (provider, provider_kind, display_name)
|
||
VALUES ('testprovider', 'provisioning', 'Test Provider')
|
||
ON CONFLICT (provider) DO NOTHING`); err != nil {
|
||
t.Fatalf("fixture provider: %v", err)
|
||
}
|
||
resourceRef := "res-" + placed.ClaimID
|
||
if _, err := env.registry.Place(ctx, domains.PlaceParams{
|
||
WorkspaceID: holder, FQDN: placed.RootFqdn, Provider: "testprovider",
|
||
ResourceRef: resourceRef, Servable: true,
|
||
}); err != nil {
|
||
t.Fatalf("place: %v", err)
|
||
}
|
||
|
||
unplaced, err := env.registry.ClaimExternal(ctx, holder, uniqueDomainsTestName())
|
||
if err != nil {
|
||
t.Fatalf("claim: %v", err)
|
||
}
|
||
|
||
code, body := env.do(env.operator, http.MethodGet, "/operator/domains")
|
||
if code != http.StatusOK {
|
||
t.Fatalf("GET /operator/domains = %d, want %d", code, http.StatusOK)
|
||
}
|
||
const placementsHeading = `mb-0">Placements</h2>`
|
||
for _, want := range []string{placementsHeading, placed.RootFqdn, "testprovider", resourceRef, ">Servable<"} {
|
||
if !strings.Contains(body, want) {
|
||
t.Errorf("placements section does not mention %q", want)
|
||
}
|
||
}
|
||
// The live claims table also has its own "Placements" column header, so
|
||
// anchor on the section heading itself, not the bare word.
|
||
sectionStart := strings.Index(body, placementsHeading)
|
||
if placementsSection := body[sectionStart:]; strings.Contains(placementsSection, unplaced.RootFqdn) {
|
||
t.Error("unplaced claim's name must not appear in the placements section -- it serves nothing")
|
||
}
|
||
}
|
||
|
||
// TestOperatorDomainsTerminalClaimsReachableWithLedgerColumns exercises task
|
||
// 9.2 end-to-end: an abandoned expiry, a system rollback, and a released
|
||
// (evidence-carrying) claim each reach the rendered claim-history section
|
||
// with the ledger-derived Outcome the model requires -- never a status-based
|
||
// guess. A still-pending (live) claim proves the two lists stay segregated:
|
||
// it appears in the live table but never in claim history.
|
||
func TestOperatorDomainsTerminalClaimsReachableWithLedgerColumns(t *testing.T) {
|
||
env := newDomainsModerationEnv(t)
|
||
ctx := context.Background()
|
||
holder := newRollbackWorkspace(t, env.database)
|
||
q := domains.New(env.database)
|
||
|
||
abandoned, err := env.registry.ClaimExternal(ctx, holder, uniqueDomainsTestName())
|
||
if err != nil {
|
||
t.Fatalf("claim: %v", err)
|
||
}
|
||
if rows, err := q.MarkClaimExpired(ctx, domains.MarkClaimExpiredParams{
|
||
ClaimID: abandoned.ClaimID, Evidence: false,
|
||
}); err != nil || rows != 1 {
|
||
t.Fatalf("expire claim = (%d, %v)", rows, err)
|
||
}
|
||
|
||
rolledBack, err := env.registry.ClaimExternal(ctx, holder, uniqueDomainsTestName())
|
||
if err != nil {
|
||
t.Fatalf("claim: %v", err)
|
||
}
|
||
if rows, err := q.MarkClaimCanceledSystem(ctx, rolledBack.ClaimID); err != nil || rows != 1 {
|
||
t.Fatalf("system-cancel claim = (%d, %v)", rows, err)
|
||
}
|
||
|
||
stillPending, err := env.registry.ClaimExternal(ctx, holder, uniqueDomainsTestName())
|
||
if err != nil {
|
||
t.Fatalf("claim: %v", err)
|
||
}
|
||
|
||
code, body := env.do(env.operator, http.MethodGet, "/operator/domains")
|
||
if code != http.StatusOK {
|
||
t.Fatalf("GET /operator/domains = %d, want %d", code, http.StatusOK)
|
||
}
|
||
|
||
historyStart := strings.Index(body, "Claim history")
|
||
if historyStart < 0 {
|
||
t.Fatal("page does not carry a claim history section")
|
||
}
|
||
live, history := body[:historyStart], body[historyStart:]
|
||
|
||
if !strings.Contains(history, abandoned.RootFqdn) || !strings.Contains(history, "abandoned") {
|
||
t.Errorf("claim history does not show %s as abandoned", abandoned.RootFqdn)
|
||
}
|
||
if !strings.Contains(history, rolledBack.RootFqdn) || !strings.Contains(history, "rolled back by the system") {
|
||
t.Errorf("claim history does not show %s as rolled back by the system", rolledBack.RootFqdn)
|
||
}
|
||
if strings.Contains(history, stillPending.RootFqdn) {
|
||
t.Errorf("live claim %s leaked into claim history", stillPending.RootFqdn)
|
||
}
|
||
if !strings.Contains(live, stillPending.RootFqdn) {
|
||
t.Errorf("live claim %s does not appear in the live table", stillPending.RootFqdn)
|
||
}
|
||
if strings.Contains(live, abandoned.RootFqdn) {
|
||
t.Errorf("terminal claim %s leaked into the live table", abandoned.RootFqdn)
|
||
}
|
||
}
|
||
|
||
// TestOperatorDomainsLiveClaimsPage: the live-claims list is governed
|
||
// (operator-list-scale, design D4). Fifty-five claims render as one page of
|
||
// fifty with the true total stated in both the pager and the header count,
|
||
// and the deployment's ordering (newest first inside the live group) holds
|
||
// across the whole result rather than per page, so the tail is on page two
|
||
// and nowhere else.
|
||
func TestOperatorDomainsLiveClaimsPage(t *testing.T) {
|
||
env := newDomainsModerationEnv(t)
|
||
holder := newRollbackWorkspace(t, env.database)
|
||
token := uniqueDomainsTestToken()
|
||
names := seedLiveClaims(t, env.database, holder, token, 55, domains.StatusActive)
|
||
|
||
code, body := env.do(env.operator, http.MethodGet, "/operator/domains?q="+token)
|
||
if code != http.StatusOK {
|
||
t.Fatalf("GET /operator/domains = %d, want %d", code, http.StatusOK)
|
||
}
|
||
live := liveSectionOf(body)
|
||
// One claim row prints its name as <code>NAME</code>; the placements
|
||
// table below wraps its names in a classed <code>, so this counts the
|
||
// live table's rows and nothing else.
|
||
if rows := strings.Count(live, "<code>"+token); rows != 50 {
|
||
t.Errorf("page one renders %d claim rows, want the %d-row page size", rows, 50)
|
||
}
|
||
if !strings.Contains(body, "Showing 1–50 of 55") {
|
||
t.Errorf("page one pager does not state the true total; body missing %q", "Showing 1–50 of 55")
|
||
}
|
||
if !strings.Contains(body, "55 live claims") {
|
||
t.Error("the header count is the page's row count, not the true total")
|
||
}
|
||
if !strings.Contains(live, names[0]) {
|
||
t.Errorf("the newest claim %s is not on page one", names[0])
|
||
}
|
||
if strings.Contains(live, names[54]) {
|
||
t.Errorf("the oldest claim %s is on page one; the ordering is being applied per page", names[54])
|
||
}
|
||
|
||
code, body = env.do(env.operator, http.MethodGet, "/operator/domains?q="+token+"&page=2")
|
||
if code != http.StatusOK {
|
||
t.Fatalf("GET page 2 = %d, want %d", code, http.StatusOK)
|
||
}
|
||
live = liveSectionOf(body)
|
||
if !strings.Contains(body, "Showing 51–55 of 55") {
|
||
t.Errorf("page two pager does not state the true total; body missing %q", "Showing 51–55 of 55")
|
||
}
|
||
if !strings.Contains(live, names[54]) {
|
||
t.Errorf("the oldest claim %s is not on page two", names[54])
|
||
}
|
||
if strings.Contains(live, names[0]) {
|
||
t.Errorf("the newest claim %s is on page two as well as page one", names[0])
|
||
}
|
||
}
|
||
|
||
// TestOperatorDomainsSearchMatchesOrganizationName: the search reaches the
|
||
// holder, not only the name held — "who is sitting on our names" is asked by
|
||
// organization at least as often as by domain (operator-list-scale: the
|
||
// search covers "the claim's root name and the owning organization's name").
|
||
func TestOperatorDomainsSearchMatchesOrganizationName(t *testing.T) {
|
||
env := newDomainsModerationEnv(t)
|
||
ctx := context.Background()
|
||
holder, other := newRollbackWorkspace(t, env.database), newRollbackWorkspace(t, env.database)
|
||
|
||
mine, err := env.registry.ClaimExternal(ctx, holder, uniqueDomainsTestName())
|
||
if err != nil {
|
||
t.Fatalf("claim: %v", err)
|
||
}
|
||
theirs, err := env.registry.ClaimExternal(ctx, other, uniqueDomainsTestName())
|
||
if err != nil {
|
||
t.Fatalf("claim: %v", err)
|
||
}
|
||
|
||
orgName := env.orgNameOf(holder)
|
||
code, body := env.do(env.operator, http.MethodGet, "/operator/domains?q="+url.QueryEscape(orgName))
|
||
if code != http.StatusOK {
|
||
t.Fatalf("GET /operator/domains = %d, want %d", code, http.StatusOK)
|
||
}
|
||
live := liveSectionOf(body)
|
||
if !strings.Contains(live, mine.RootFqdn) {
|
||
t.Errorf("a search for the holder %q does not find its claim %s", orgName, mine.RootFqdn)
|
||
}
|
||
if strings.Contains(live, theirs.RootFqdn) {
|
||
t.Errorf("a search for the holder %q also returns another organization's claim %s", orgName, theirs.RootFqdn)
|
||
}
|
||
if !strings.Contains(body, "Showing 1–1 of 1") {
|
||
t.Errorf("the search total is not the match count; body missing %q", "Showing 1–1 of 1")
|
||
}
|
||
}
|
||
|
||
// TestOperatorDomainsStatusFacetNarrowsToPending: the facet filters over the
|
||
// live vocabulary only, composes with the search, and rides in the URL so
|
||
// the view is addressable (operator-list-scale).
|
||
func TestOperatorDomainsStatusFacetNarrowsToPending(t *testing.T) {
|
||
env := newDomainsModerationEnv(t)
|
||
holder := newRollbackWorkspace(t, env.database)
|
||
token := uniqueDomainsTestToken()
|
||
active := seedLiveClaims(t, env.database, holder, token+"act", 1, domains.StatusActive)[0]
|
||
waiting := seedLiveClaims(t, env.database, holder, token+"pen", 1, domains.StatusPending)[0]
|
||
|
||
code, body := env.do(env.operator, http.MethodGet, "/operator/domains?q="+token+"&status=pending")
|
||
if code != http.StatusOK {
|
||
t.Fatalf("GET /operator/domains = %d, want %d", code, http.StatusOK)
|
||
}
|
||
live := liveSectionOf(body)
|
||
if !strings.Contains(live, waiting) {
|
||
t.Errorf("the pending facet drops the pending claim %s", waiting)
|
||
}
|
||
if strings.Contains(live, active) {
|
||
t.Errorf("the pending facet still shows the active claim %s", active)
|
||
}
|
||
if !strings.Contains(body, "Showing 1–1 of 1") {
|
||
t.Errorf("the faceted total is not the match count; body missing %q", "Showing 1–1 of 1")
|
||
}
|
||
if !strings.Contains(body, "status=pending") {
|
||
t.Error("the facet is not carried in the controls' URLs, so the view is not addressable")
|
||
}
|
||
|
||
// An unknown facet value is ignored rather than obeyed, the same as an
|
||
// unknown page: both claims come back.
|
||
if _, body = env.do(env.operator, http.MethodGet, "/operator/domains?q="+token+"&status=expired"); !strings.Contains(body, "Showing 1–2 of 2") {
|
||
t.Error("a facet value outside the live vocabulary narrowed the list instead of being ignored")
|
||
}
|
||
}
|