Files
member-console/internal/server/operator_domains_db_test.go
T
cgalo5758 12f1d3fc00 Fix the four Slice 3 walk findings and page every operator list
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."
2026-09-12 01:16:17 -05:00

620 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 12 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 150 of 55") {
t.Errorf("page one pager does not state the true total; body missing %q", "Showing 150 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 5155 of 55") {
t.Errorf("page two pager does not state the true total; body missing %q", "Showing 5155 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 11 of 1") {
t.Errorf("the search total is not the match count; body missing %q", "Showing 11 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 11 of 1") {
t.Errorf("the faceted total is not the match count; body missing %q", "Showing 11 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 12 of 2") {
t.Error("a facet value outside the live vocabulary narrowed the list instead of being ignored")
}
}