- 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
430 lines
16 KiB
Go
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)
|
|
}
|
|
}
|