Files
member-console/internal/server/operator_domains_db_test.go
T
cgalo5758 c85ac6acdc Add domain claim lifecycle safeguards
Make claim windows and workspace caps configurable, and enforce
initiation
and abandonment budgets without penalizing DNS evidence or system
failures.
Add operator visibility into live claims and default verification to 24
hours.
2026-07-25 00:40:24 -05:00

307 lines
11 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)
}
if strings.Contains(rec.Body.String(), name) {
t.Error("the re-rendered list still shows the 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)
}
}