Add an append-only ledger of entitlement set rule changes with per-pool effect rows, a preview-and-commit rule change flow, and an automatic drain that settles deferred recomputations. Rules gain a tier reduction policy, resource keys declare over-limit behavior, and the materializer now lowers limits when a rule stops applying. Add entitlement set rule change ledger and preview flow Add an append-only ledger of entitlement set rule changes with a preview-and-commit operator flow. Rule writes now go through an enclosed `core.commit_rule_change` function that files an act row and one obligation per carrying pool, with a drain workflow settling deferred recomputations. The preview dry-runs the materializer with a rule overlay and renders per-pool buckets, reduction-policy disclosures, and provider over-limit consequences. Materializing transactions take a shared advisory rendezvous that rule changes hold exclusively, enforced by a possession assertion. Add History and Entitlement changes surfaces, a rule-less warning on five product-selection surfaces, and a `tier_reduction_policy` column that gates FedWiki parking.
436 lines
16 KiB
Go
436 lines
16 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/alexedwards/scs/v2"
|
|
"github.com/google/uuid"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/instance"
|
|
stripestore "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
|
|
)
|
|
|
|
// DB-backed derivation coverage for the operator setup checklist (design
|
|
// D12 / operator-setup-checklist). Point TEST_DATABASE_URL at a dedicated
|
|
// scratch database before running these — the derivation predicates are
|
|
// whole-table EXISTS checks, so they are not safe to run against a
|
|
// database other tests are concurrently writing real rows into.
|
|
//
|
|
// Reuses testDB, defined once in operator_plan_ladders_test.go (same
|
|
// package). Every test's fixtures live inside a transaction rolled back via
|
|
// defer, matching this package's existing DB-backed tests, so nothing here
|
|
// commits; cleanSetupSlate still runs first as a safety net against a stale
|
|
// committed row surviving an earlier interrupted run.
|
|
func cleanSetupSlate(t *testing.T, db *sql.DB) {
|
|
t.Helper()
|
|
stmts := []string{
|
|
// Reset org_types' default-plan reference before dropping plan
|
|
// ladders — fk_org_types_default_plan_ladder blocks it otherwise.
|
|
// org_types itself must never be truncated: its seeded rows are a
|
|
// boot invariant, not fixture data this file owns, and CASCADE
|
|
// truncating core.plan_ladders would sweep it in (org_types is the
|
|
// dependent side of that one FK) — so plan_ladders' own dependents
|
|
// are cleared explicitly below instead of via CASCADE on it.
|
|
`UPDATE core.org_types SET default_plan_ladder_id = NULL WHERE default_plan_ladder_id IS NOT NULL`,
|
|
`DELETE FROM core.pool_provision_transitions`,
|
|
`DELETE FROM core.pool_provision_ladders`,
|
|
`DELETE FROM core.plan_ladder_tiers`,
|
|
`DELETE FROM core.plan_ladders`,
|
|
// CASCADE here only reaches tables that reference products/
|
|
// entitlement_sets (prices, Stripe mappings, grants, pool
|
|
// provisions, subscription items, entitlement_set_rules, ...) —
|
|
// verified against pg_constraint that org_types is not among them.
|
|
`TRUNCATE core.entitlement_sets, core.products CASCADE`,
|
|
}
|
|
for _, stmt := range stmts {
|
|
if _, err := db.Exec(stmt); err != nil {
|
|
t.Fatalf("clean setup slate (%s): %v", stmt, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// setupHandler builds an OperatorHandler backed by tx-scoped queriers plus a
|
|
// discard logger — enough to exercise DeriveSetupState, nothing else.
|
|
// InstanceSettings is left nil, matching production's degrade-to-
|
|
// "never dismissed" behavior for a handler that doesn't wire one up.
|
|
func setupHandler(tx *sql.Tx) *server.OperatorHandler {
|
|
return &server.OperatorHandler{
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
BillingQ: billing.New(tx),
|
|
EntitlementsQ: entitlements.New(tx),
|
|
OrgQ: organization.New(tx),
|
|
StripeQ: stripestore.New(tx),
|
|
}
|
|
}
|
|
|
|
func stepComplete(state server.SetupState, key string) (complete, found bool) {
|
|
for _, s := range state.Steps {
|
|
if s.Key == key {
|
|
return s.Complete, true
|
|
}
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
func assertStep(t *testing.T, state server.SetupState, key string, want bool) {
|
|
t.Helper()
|
|
got, found := stepComplete(state, key)
|
|
if !found {
|
|
t.Fatalf("no setup step with key %q", key)
|
|
}
|
|
if got != want {
|
|
t.Errorf("step %q Complete = %v, want %v", key, got, want)
|
|
}
|
|
}
|
|
|
|
// A freshly migrated, empty database: every step is incomplete, and the
|
|
// overview banner's visibility predicate agrees (design D12: "Steps SHALL
|
|
// be equal" — no required/conditional split, so the banner shows whenever
|
|
// any step at all is outstanding).
|
|
func TestSetupStateFreshDatabaseAllStepsIncomplete(t *testing.T) {
|
|
database := testDB(t)
|
|
cleanSetupSlate(t, database)
|
|
ctx := context.Background()
|
|
|
|
tx, err := entitlements.BeginMaterializing(ctx, database, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
state := setupHandler(tx).DeriveSetupState(ctx)
|
|
|
|
if len(state.Steps) != 6 {
|
|
t.Fatalf("got %d steps, want 6 (integrations, entitlement-set, product, price-sync, ladder, org-type-default)", len(state.Steps))
|
|
}
|
|
if state.Steps[0].Key != "integrations" {
|
|
t.Errorf("Steps[0].Key = %q, want %q (design D12: integrations is first)", state.Steps[0].Key, "integrations")
|
|
}
|
|
for _, s := range state.Steps {
|
|
if s.Complete {
|
|
t.Errorf("step %q reported complete on a fresh database", s.Key)
|
|
}
|
|
}
|
|
if state.DoneCount() != 0 {
|
|
t.Errorf("DoneCount() = %d, want 0 on a fresh database", state.DoneCount())
|
|
}
|
|
if state.Total() != 6 {
|
|
t.Errorf("Total() = %d, want 6", state.Total())
|
|
}
|
|
if !state.ShowBanner() {
|
|
t.Error("ShowBanner() = false on a fresh database, want true (nothing dismissed, every step incomplete)")
|
|
}
|
|
}
|
|
|
|
// operator-setup-checklist, "The entitlement-set step names the product
|
|
// route": with no set yet, the step names the second route to one and
|
|
// links the product create page, and neither it nor the Products step
|
|
// tells the operator to do the other first (design D14).
|
|
func TestSetupStateEntitlementSetStepNamesTheProductRoute(t *testing.T) {
|
|
database := testDB(t)
|
|
cleanSetupSlate(t, database)
|
|
ctx := context.Background()
|
|
|
|
tx, err := entitlements.BeginMaterializing(ctx, database, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
state := setupHandler(tx).DeriveSetupState(ctx)
|
|
var set, product server.SetupStep
|
|
for _, s := range state.Steps {
|
|
switch s.Key {
|
|
case "entitlement-set":
|
|
set = s
|
|
case "product":
|
|
product = s
|
|
}
|
|
}
|
|
if set.Note != "Or create one with your first product." {
|
|
t.Errorf("entitlement-set Note = %q, want the second route named", set.Note)
|
|
}
|
|
if set.NoteHref != "/operator/products/new" {
|
|
t.Errorf("entitlement-set NoteHref = %q, want the product create page", set.NoteHref)
|
|
}
|
|
if set.NoteLinkText == "" {
|
|
t.Error("the note's link carries no text")
|
|
}
|
|
for _, copy := range []string{set.Description, set.Unlocks, product.Description, product.Unlocks, product.Note} {
|
|
if strings.Contains(strings.ToLower(copy), "first create") || strings.Contains(strings.ToLower(copy), "before creating") {
|
|
t.Errorf("no step's copy may tell the operator to complete the other first, got %q", copy)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Minimal fixtures flip each predicate one by one, in chain order. Each
|
|
// assertion checks the step just built AND that steps further down the
|
|
// chain have not moved — the derivation must not cross-credit. This
|
|
// fixture never configures an integration, so the "integrations" step
|
|
// stays incomplete throughout (by design: with zero configured integrations
|
|
// the step cannot derive complete), which the DoneCount checks below
|
|
// account for.
|
|
func TestSetupStateDerivationFlipsPerStep(t *testing.T) {
|
|
database := testDB(t)
|
|
cleanSetupSlate(t, database)
|
|
ctx := context.Background()
|
|
|
|
tx, err := entitlements.BeginMaterializing(ctx, database, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
bq := billing.New(tx)
|
|
eq := entitlements.New(tx)
|
|
sq := stripestore.New(tx)
|
|
h := setupHandler(tx)
|
|
|
|
// 1. Entitlement set.
|
|
es, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
|
|
Name: "setup-test-set-" + uuid.New().String()[:8],
|
|
IsActive: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create entitlement set: %v", err)
|
|
}
|
|
state := h.DeriveSetupState(ctx)
|
|
assertStep(t, state, "entitlement-set", true)
|
|
assertStep(t, state, "product", false)
|
|
|
|
// 2. A draft product does NOT complete the product step: the step is
|
|
// keyed on a published product, because the operator UI's create path
|
|
// publishes at creation (operator_products.go) and a draft-only catalog
|
|
// delivers nothing to members.
|
|
product, err := bq.CreateProduct(ctx, billing.CreateProductParams{
|
|
Name: "Setup Test Product",
|
|
IsActive: true,
|
|
IsPublic: true,
|
|
EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(es.SetID), Valid: true},
|
|
LifecycleStatus: "draft",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create product: %v", err)
|
|
}
|
|
state = h.DeriveSetupState(ctx)
|
|
assertStep(t, state, "product", false)
|
|
assertStep(t, state, "price-sync", false)
|
|
assertStep(t, state, "ladder", false)
|
|
|
|
// 3. A published product completes it (a second product created straight
|
|
// to "published" — no lifecycle-transition query is generated to flip the
|
|
// first in place).
|
|
_, err = bq.CreateProduct(ctx, billing.CreateProductParams{
|
|
Name: "Setup Test Product (published)",
|
|
IsActive: true,
|
|
IsPublic: true,
|
|
EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(es.SetID), Valid: true},
|
|
LifecycleStatus: "published",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create published product: %v", err)
|
|
}
|
|
state = h.DeriveSetupState(ctx)
|
|
assertStep(t, state, "product", true)
|
|
assertStep(t, state, "price-sync", false)
|
|
|
|
// 4. Price + Stripe sync: a price alone is not enough — the step is the
|
|
// composed pair (AnyActivePrice && AnyMappedPrice).
|
|
price, err := bq.CreatePrice(ctx, billing.CreatePriceParams{
|
|
ProductID: product.ProductID,
|
|
Currency: "usd",
|
|
UnitAmount: 500,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create price: %v", err)
|
|
}
|
|
state = h.DeriveSetupState(ctx)
|
|
assertStep(t, state, "price-sync", false)
|
|
|
|
if _, err := sq.UpsertPriceMapping(ctx, stripestore.UpsertPriceMappingParams{
|
|
PriceID: price.PriceID,
|
|
StripePriceID: sql.NullString{String: "price_setup_test", Valid: true},
|
|
SyncStatus: "synced",
|
|
}); err != nil {
|
|
t.Fatalf("upsert price mapping: %v", err)
|
|
}
|
|
state = h.DeriveSetupState(ctx)
|
|
assertStep(t, state, "price-sync", true)
|
|
|
|
// 5. Plan ladder with a rank-0 tier — a ladder alone is not enough; the
|
|
// step needs the tier (AnyRankZeroTier).
|
|
ladder, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{
|
|
Name: "Setup Test Ladder " + uuid.New().String()[:8],
|
|
IsActive: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create plan ladder: %v", err)
|
|
}
|
|
state = h.DeriveSetupState(ctx)
|
|
assertStep(t, state, "ladder", false)
|
|
|
|
if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{
|
|
PlanLadderID: ladder.PlanLadderID,
|
|
ProductID: product.ProductID,
|
|
}); err != nil {
|
|
t.Fatalf("create plan ladder tier: %v", err)
|
|
}
|
|
state = h.DeriveSetupState(ctx)
|
|
assertStep(t, state, "ladder", true)
|
|
|
|
// Four of six steps are done now (entitlement-set, product, price-sync,
|
|
// ladder); integrations (never configured in this fixture) and
|
|
// org-type-default remain outstanding. Every step counts equally now
|
|
// (design D12, round 2: no required/conditional split), so DoneCount
|
|
// reflects exactly that, not a "required steps only" subset.
|
|
if got, want := state.DoneCount(), 4; got != want {
|
|
t.Errorf("DoneCount() = %d, want %d after entitlement-set/product/price-sync/ladder", got, want)
|
|
}
|
|
assertStep(t, state, "org-type-default", false)
|
|
|
|
// 6. Org-type default plan (last step in the chain).
|
|
orgType := "setup-test-" + uuid.New().String()[:8]
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO core.org_types (org_type, display_name, is_active, default_plan_ladder_id) VALUES ($1, $2, true, $3)`,
|
|
orgType, "Setup Test Org Type", ladder.PlanLadderID,
|
|
); err != nil {
|
|
t.Fatalf("seed org type: %v", err)
|
|
}
|
|
state = h.DeriveSetupState(ctx)
|
|
assertStep(t, state, "org-type-default", true)
|
|
// Five of six steps are done; "integrations" alone stays incomplete
|
|
// because this fixture never configures one — the checklist honestly
|
|
// reflects that rather than crediting a step nothing satisfied.
|
|
if got, want := state.DoneCount(), 5; got != want {
|
|
t.Errorf("DoneCount() = %d, want %d once every step but integrations is complete", got, want)
|
|
}
|
|
assertStep(t, state, "integrations", false)
|
|
}
|
|
|
|
// Entitlement sets have no hard delete in this app: "deleting" one is
|
|
// UpdateEntitlementSet with IsActive=false, the same action the operator
|
|
// entitlement-sets page's Hidden toggle performs. AnyActiveEntitlementSet
|
|
// reads is_active directly, so hiding the last one must un-complete the
|
|
// step on the very next render — completion is derived, never sticky.
|
|
func TestSetupStateEntitlementSetDeactivationUncompletesStep(t *testing.T) {
|
|
database := testDB(t)
|
|
cleanSetupSlate(t, database)
|
|
ctx := context.Background()
|
|
|
|
tx, err := entitlements.BeginMaterializing(ctx, database, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
eq := entitlements.New(tx)
|
|
h := setupHandler(tx)
|
|
|
|
es, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
|
|
Name: "setup-test-deactivate-set-" + uuid.New().String()[:8],
|
|
IsActive: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create entitlement set: %v", err)
|
|
}
|
|
|
|
state := h.DeriveSetupState(ctx)
|
|
assertStep(t, state, "entitlement-set", true)
|
|
|
|
if _, err := eq.UpdateEntitlementSet(ctx, entitlements.UpdateEntitlementSetParams{
|
|
SetID: es.SetID,
|
|
Name: es.Name,
|
|
IsActive: false,
|
|
}); err != nil {
|
|
t.Fatalf("deactivate entitlement set: %v", err)
|
|
}
|
|
|
|
state = h.DeriveSetupState(ctx)
|
|
assertStep(t, state, "entitlement-set", false)
|
|
}
|
|
|
|
// TestDismissSetupBannerWritesInstanceSetting is a DB-backed handler test
|
|
// for the setup banner's dismiss route (design D12/D28,
|
|
// POST /partials/operator/setup/dismiss): the handler writes the instance
|
|
// setting deployment-wide, recording the signed-in operator as the actor,
|
|
// and responds with an empty 200 body — the banner's own
|
|
// hx-swap="outerHTML" removes it with nothing left in its place. Runs
|
|
// against the real (non-transactional) test database, like
|
|
// internal/instance's own settings_db_test.go, because
|
|
// OperatorPartialsHandler.Database is a concrete *sql.DB the handler opens
|
|
// its own instance.Store from; the write is cleaned up afterward.
|
|
func TestDismissSetupBannerWritesInstanceSetting(t *testing.T) {
|
|
database := testDB(t)
|
|
ctx := context.Background()
|
|
t.Cleanup(func() {
|
|
if _, err := database.ExecContext(ctx,
|
|
`DELETE FROM core.instance_settings WHERE key = $1`, string(instance.SetupBannerDismissed)); err != nil {
|
|
t.Errorf("cleanup instance setting: %v", err)
|
|
}
|
|
})
|
|
|
|
sm := scs.New()
|
|
authCfg := &auth.Config{SessionManager: sm}
|
|
handler, err := server.NewOperatorPartialsHandler(server.OperatorPartialsConfig{
|
|
Database: database,
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
AuthConfig: authCfg,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewOperatorPartialsHandler: %v", err)
|
|
}
|
|
|
|
sessionCtx, err := sm.Load(ctx, "")
|
|
if err != nil {
|
|
t.Fatalf("session Load: %v", err)
|
|
}
|
|
sm.Put(sessionCtx, "authenticated", true)
|
|
sm.Put(sessionCtx, "email", "op@example.com")
|
|
sm.Put(sessionCtx, "roles", []string{server.OperatorRole})
|
|
|
|
req := httptest.NewRequestWithContext(sessionCtx, http.MethodPost, "/partials/operator/setup/dismiss", nil)
|
|
rec := httptest.NewRecorder()
|
|
handler.DismissSetupBanner(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("DismissSetupBanner status = %d, want 200, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if body := rec.Body.String(); body != "" {
|
|
t.Errorf("DismissSetupBanner body = %q, want empty", body)
|
|
}
|
|
|
|
store := instance.NewStore(database)
|
|
dismissed, err := store.GetBool(ctx, instance.SetupBannerDismissed)
|
|
if err != nil {
|
|
t.Fatalf("GetBool after dismiss: %v", err)
|
|
}
|
|
if !dismissed {
|
|
t.Error("instance setting not recorded true after DismissSetupBanner")
|
|
}
|
|
|
|
var updatedBy sql.NullString
|
|
if err := database.QueryRowContext(ctx,
|
|
`SELECT updated_by FROM core.instance_settings WHERE key = $1`, string(instance.SetupBannerDismissed),
|
|
).Scan(&updatedBy); err != nil {
|
|
t.Fatalf("read updated_by: %v", err)
|
|
}
|
|
if !updatedBy.Valid || updatedBy.String != "op@example.com" {
|
|
t.Errorf("updated_by = %+v, want op@example.com", updatedBy)
|
|
}
|
|
}
|