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.
307 lines
11 KiB
Go
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)
|
|
}
|
|
}
|