Files
member-console/internal/server/operator_domains_db_test.go
T
cgalo5758 9b96e9c9e9 Rework operator IA and unify UI vocabulary
- 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
2026-08-23 17:12:42 -05:00

430 lines
16 KiB
Go

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"
"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 fmt.Sprintf("m%d.example", time.Now().UnixNano())
}
// 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.
func TestOperatorDomainsPageListsEveryWorkspaceAndThePolicy(t *testing.T) {
env := newDomainsModerationEnv(t)
ctx := context.Background()
alice, bob := newRollbackWorkspace(t, env.database), newRollbackWorkspace(t, env.database)
waiting, err := env.registry.ClaimExternal(ctx, alice, uniqueDomainsTestName())
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, 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)
}
for _, want := range []string{
waiting.RootFqdn, other.RootFqdn,
env.orgNameOf(alice), env.orgNameOf(bob),
"Effective claim policy",
`href="/operator/domains"`, // the sidebar entry
} {
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 is a per-claim fact an operator must be able to see: it is why
// a holder's abandonment went uncounted.
if !strings.Contains(body, "seen") {
t.Error("page does not mark the claim whose zone control was observed")
}
}
// 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 (slug, provider_kind, display_name)
VALUES ('testprovider', 'provisioning', 'Test Provider')
ON CONFLICT (slug) 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 (slug, provider_kind, display_name)
VALUES ('testprovider', 'provisioning', 'Test Provider')
ON CONFLICT (slug) 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)
}
}