Files
member-console/internal/entitlements/materialize_test.go
T
cgalo5758 f8a3478f2a Rebuild the entitlement set Rules surface as a staged batch
The Rules section is one record table grouped by kind, Limit then
Boolean, on fixed columns, edited in place: Edit opens a row's controls
in their columns, Add rule opens a dense row above the table, and every
change is staged into a tray that lists the deltas with Undo and applies
them as one rule-change act. The reduction policy is a column of the
rule beside its limit. History shows counts only. Group rows are a quiet
heading rather than a divider, the maintainer's pick from four rounds of
outside-model ideation.

Dense rows align to the top and render each error under its control in
every form family (design D16), replacing the below-row error block; the
forms library gains the batch form (rows plus one tray) and the RowField
dense and label-hidden options. Migration 00019 records the governing
reduction policy on effect rows.

Archive staged-rule-changes with its spec updates (entitlement-set-
management, entitlement-set-history, entitlements, form-library,
form-conventions, ui-quality-gate). Screens accepted 2026-09-19.
2026-09-19 19:46:09 -05:00

1285 lines
42 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package entitlements_test
import (
"context"
"database/sql"
"fmt"
"os"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/migrate"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/provisioning"
"github.com/google/uuid"
_ "github.com/jackc/pgx/v5/stdlib"
)
func testDB(t *testing.T) *sql.DB {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set, skipping integration test")
}
database, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
// Run migrations
sources := migrate.Sources()
if err := db.RunMigrations(database, sources); err != nil {
t.Fatalf("failed to run migrations: %v", err)
}
t.Cleanup(func() { database.Close() })
return database
}
func TestMaterializeSingleProvision(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
q := entitlements.New(tx)
orgQ := organization.New(tx)
idQ := identity.New(tx)
// Setup: create user, person, org, workspace
user, err := idQ.CreateUser(ctx, "test-"+uuid.New().String())
if err != nil {
t.Fatalf("create user: %v", err)
}
person, err := idQ.CreatePerson(ctx, identity.CreatePersonParams{
UserID: user.UserID,
DisplayName: "Test User",
PrimaryEmail: "test@example.com",
PrimaryEmailVerified: true,
})
if err != nil {
t.Fatalf("create person: %v", err)
}
org, err := orgQ.CreateOrganization(ctx, organization.CreateOrganizationParams{
Name: "Test Org",
OrgType: "personal",
OwnerPersonID: person.PersonID,
})
if err != nil {
t.Fatalf("create org: %v", err)
}
// Create pool and provision
pool, err := q.CreateResourcePool(ctx, entitlements.CreateResourcePoolParams{
OrgID: org.OrgID,
Name: "Default",
PoolType: "default",
IsAutoManaged: true,
})
if err != nil {
t.Fatalf("create pool: %v", err)
}
// Create a test product with entitlement set (5 sites, not per-unit)
tp := createTestProduct(t, ctx, tx, "TestProduct", 5, false)
// Create a grant
grant, err := q.CreateGrant(ctx, entitlements.CreateGrantParams{
ProductID: tp.productID,
GrantedToOrgID: uuid.NullUUID{
UUID: uuid.MustParse(org.OrgID),
Valid: true,
},
GrantedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(person.PersonID), Valid: true},
GrantReason: "promotional",
Quantity: 1,
ValidFrom: time.Now(),
})
if err != nil {
t.Fatalf("create grant: %v", err)
}
// Confer the grant onto the pool (core.confer is the only legal writer
// of pool_provisions).
_, outcome, err := q.Confer(ctx, entitlements.ConferParams{
PoolID: pool.PoolID,
ProductID: tp.productID,
GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant.GrantID), Valid: true},
Quantity: 1,
})
if err != nil {
t.Fatalf("confer: %v", err)
}
if outcome != "created" {
t.Fatalf("confer outcome = %q, want created", outcome)
}
// Materialize
err = entitlements.MaterializePoolEntitlements(ctx, q, pool.PoolID)
if err != nil {
t.Fatalf("materialize: %v", err)
}
// Verify entitlement was created
ent, err := q.GetNumericEntitlementByPoolAndResource(ctx, entitlements.GetNumericEntitlementByPoolAndResourceParams{
PoolID: pool.PoolID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
t.Fatalf("get entitlement: %v", err)
}
if ent.ResourceLimit != 5 {
t.Errorf("expected resource_limit=5, got %d", ent.ResourceLimit)
}
// Verify usage record was created
usage, err := q.GetUsageByPoolAndResource(ctx, entitlements.GetUsageByPoolAndResourceParams{
PoolID: pool.PoolID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
t.Fatalf("get usage: %v", err)
}
if usage.CurrentUsage != 0 {
t.Errorf("expected current_usage=0, got %d", usage.CurrentUsage)
}
}
// setupTestOrg is a helper that creates user, person, org, workspace, pool, and pool assignment.
// Returns all the created records needed for entitlement tests.
type testOrg struct {
person identity.Person
org organization.Organization
workspace organization.Workspace
pool entitlements.ResourcePool
}
func setupTestOrg(t *testing.T, ctx context.Context, tx *sql.Tx) testOrg {
t.Helper()
q := entitlements.New(tx)
orgQ := organization.New(tx)
idQ := identity.New(tx)
user, err := idQ.CreateUser(ctx, "test-"+uuid.New().String())
if err != nil {
t.Fatalf("create user: %v", err)
}
person, err := idQ.CreatePerson(ctx, identity.CreatePersonParams{
UserID: user.UserID,
DisplayName: "Test User",
PrimaryEmail: fmt.Sprintf("test-%s@example.com", uuid.New().String()[:8]),
PrimaryEmailVerified: true,
})
if err != nil {
t.Fatalf("create person: %v", err)
}
org, err := orgQ.CreateOrganization(ctx, organization.CreateOrganizationParams{
Name: "Test Org",
OrgType: "personal",
OwnerPersonID: person.PersonID,
})
if err != nil {
t.Fatalf("create org: %v", err)
}
workspace, err := orgQ.CreateWorkspace(ctx, organization.CreateWorkspaceParams{
OrgID: org.OrgID,
Name: "Default",
})
if err != nil {
t.Fatalf("create workspace: %v", err)
}
pool, err := q.CreateResourcePool(ctx, entitlements.CreateResourcePoolParams{
OrgID: org.OrgID,
Name: "Default",
PoolType: "default",
IsAutoManaged: true,
})
if err != nil {
t.Fatalf("create pool: %v", err)
}
_, err = q.CreatePoolAssignment(ctx, entitlements.CreatePoolAssignmentParams{
PoolID: pool.PoolID,
WorkspaceID: workspace.WorkspaceID,
IsPrimary: true,
})
if err != nil {
t.Fatalf("create pool assignment: %v", err)
}
return testOrg{person: person, org: org, workspace: workspace, pool: pool}
}
// addSetRule files one rule through core.commit_rule_change, the only write
// path to core.entitlement_set_rules since migration 00018 revoked the DML,
// and returns the rule id it wrote. The caller's transaction must hold the
// exclusive materialization rendezvous, so it comes from BeginRuleChange.
func addSetRule(t *testing.T, ctx context.Context, q *entitlements.Queries, setID string, rule entitlements.RuleFields) string {
t.Helper()
row, err := entitlements.CommitRuleChangeTx(ctx, q, entitlements.CommitRuleChangeInput{
SetID: setID,
ChangeKind: entitlements.ChangeKindRuleAdded,
Rule: rule,
ActorType: entitlements.ActorTypeSystem,
})
if err != nil {
t.Fatalf("add rule to set %s: %v", setID, err)
}
return row.RuleID
}
// testProduct holds identifiers for a product created by createTestProduct.
type testProduct struct {
productID string
setID string
}
// createTestProduct creates a billing product with an entitlement set containing a "fedwiki_sites" limit rule.
// sitesLimit is the base limit, perUnit controls whether it scales with quantity.
func createTestProduct(t *testing.T, ctx context.Context, tx *sql.Tx, name string, sitesLimit int64, perUnit bool) testProduct {
t.Helper()
q := entitlements.New(tx)
set, err := q.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: name + " Set",
IsActive: true,
})
if err != nil {
t.Fatalf("create entitlement set for %s: %v", name, err)
}
addSetRule(t, ctx, q, set.SetID, entitlements.RuleFields{
RuleType: "limit",
ResourceKey: sql.NullString{String: "fedwiki_sites", Valid: true},
ResourceValue: sql.NullInt64{Int64: sitesLimit, Valid: true},
ResourcePerUnit: sql.NullBool{Bool: perUnit, Valid: true},
StackingPolicy: sql.NullString{String: "additive", Valid: true},
})
// display_category stays NULL (presentation-only label per Doc 41
// Decision 136); lifecycle_status must be 'published' because core.confer
// refuses draft products.
var productID string
err = tx.QueryRowContext(ctx,
`INSERT INTO core.products (name, is_active, is_public, lifecycle_status, entitlement_set_id)
VALUES ($1, TRUE, TRUE, 'published', $2)
RETURNING product_id`,
name, set.SetID,
).Scan(&productID)
if err != nil {
t.Fatalf("create product %s: %v", name, err)
}
return testProduct{productID: productID, setID: set.SetID}
}
// createGrantAndProvision records a grant for the given product and quantity,
// confers it onto the org's pool via core.confer, then materializes.
func createGrantAndProvision(t *testing.T, ctx context.Context, tx *sql.Tx, q *entitlements.Queries, to testOrg, productID string, quantity int32) entitlements.Grant {
t.Helper()
grant, err := q.CreateGrant(ctx, entitlements.CreateGrantParams{
ProductID: productID,
GrantedToOrgID: uuid.NullUUID{
UUID: uuid.MustParse(to.org.OrgID),
Valid: true,
},
GrantedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(to.person.PersonID), Valid: true},
GrantReason: "manual",
Quantity: quantity,
ValidFrom: time.Now(),
})
if err != nil {
t.Fatalf("create grant: %v", err)
}
_, outcome, err := q.Confer(ctx, entitlements.ConferParams{
PoolID: to.pool.PoolID,
ProductID: productID,
GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant.GrantID), Valid: true},
Quantity: quantity,
})
if err != nil {
t.Fatalf("confer: %v", err)
}
if outcome != "created" {
t.Fatalf("confer outcome = %q, want created", outcome)
}
err = entitlements.MaterializePoolEntitlements(ctx, q, to.pool.PoolID)
if err != nil {
t.Fatalf("materialize: %v", err)
}
return grant
}
func getEntitlementLimit(t *testing.T, ctx context.Context, q *entitlements.Queries, poolID string) int64 {
t.Helper()
ent, err := q.GetNumericEntitlementByPoolAndResource(ctx, entitlements.GetNumericEntitlementByPoolAndResourceParams{
PoolID: poolID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
t.Fatalf("get entitlement: %v", err)
}
return ent.ResourceLimit
}
func getUsage(t *testing.T, ctx context.Context, q *entitlements.Queries, poolID string) int64 {
t.Helper()
usage, err := q.GetUsageByPoolAndResource(ctx, entitlements.GetUsageByPoolAndResourceParams{
PoolID: poolID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
t.Fatalf("get usage: %v", err)
}
return usage.CurrentUsage
}
// 10.2 Test materialization: additive stacking with multiple provisions
func TestMaterializeAdditiveStacking(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
q := entitlements.New(tx)
to := setupTestOrg(t, ctx, tx)
// Test product: 5 sites per grant (not per-unit)
product := createTestProduct(t, ctx, tx, "TestProduct", 5, false)
// First grant: should get limit=5
createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1)
if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 5 {
t.Errorf("after first grant: expected limit=5, got %d", limit)
}
// Second grant: additive stacking should give limit=10
createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1)
if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 10 {
t.Errorf("after second grant: expected limit=10, got %d", limit)
}
// Also test per-unit product: 1 site × quantity
siteCreditProduct := createTestProduct(t, ctx, tx, "TestSiteCredit", 1, true)
createGrantAndProvision(t, ctx, tx, q, to, siteCreditProduct.productID, 3)
// 5 + 5 + 3 = 13
if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 13 {
t.Errorf("after site credit grant (qty=3): expected limit=13, got %d", limit)
}
}
// 10.3 Test materialization: provision removal recomputes limit correctly
func TestMaterializeProvisionRemoval(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
q := entitlements.New(tx)
to := setupTestOrg(t, ctx, tx)
product := createTestProduct(t, ctx, tx, "TestProduct", 5, false)
// Create two grants
grant1 := createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1)
createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1)
// Limit should be 10
if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 10 {
t.Fatalf("expected limit=10, got %d", limit)
}
// Revoke first grant (the decree), end its conferral (the enactment),
// re-materialize
_, err = q.RevokeGrant(ctx, entitlements.RevokeGrantParams{
GrantID: grant1.GrantID,
})
if err != nil {
t.Fatalf("revoke grant: %v", err)
}
ended, err := q.EndConferral(ctx, entitlements.EndConferralParams{
GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant1.GrantID), Valid: true},
})
if err != nil {
t.Fatalf("end conferral: %v", err)
}
if len(ended) != 1 {
t.Fatalf("expected 1 ended provision, got %d", len(ended))
}
err = entitlements.MaterializePoolEntitlements(ctx, q, to.pool.PoolID)
if err != nil {
t.Fatalf("re-materialize: %v", err)
}
// Limit should drop to 5
if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 5 {
t.Errorf("after revocation: expected limit=5, got %d", limit)
}
}
// 10.4 Test materialization: idempotency (repeated calls produce same result)
func TestMaterializeIdempotency(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
q := entitlements.New(tx)
to := setupTestOrg(t, ctx, tx)
product := createTestProduct(t, ctx, tx, "TestProduct", 5, false)
createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1)
// Call materialize multiple times
for i := 0; i < 3; i++ {
err = entitlements.MaterializePoolEntitlements(ctx, q, to.pool.PoolID)
if err != nil {
t.Fatalf("materialize iteration %d: %v", i, err)
}
}
// Limit should still be 5
if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 5 {
t.Errorf("expected limit=5 after repeated materialize, got %d", limit)
}
if usage := getUsage(t, ctx, q, to.pool.PoolID); usage != 0 {
t.Errorf("expected usage=0 after repeated materialize, got %d", usage)
}
}
// 10.5 Test grant flow: create grant → entitlements materialize → site creation succeeds
func TestGrantFlowSiteCreation(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
q := entitlements.New(tx)
fwQ := fwmod.New(tx)
to := setupTestOrg(t, ctx, tx)
product := createTestProduct(t, ctx, tx, "TestProduct", 5, false)
// Grant gives 5 sites
createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1)
// Atomic increment should succeed (usage 0 < limit 5)
result, err := q.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{
WorkspaceID: to.workspace.WorkspaceID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
t.Fatalf("atomic increment: %v", err)
}
rows, _ := result.RowsAffected()
if rows != 1 {
t.Fatalf("expected 1 row affected, got %d", rows)
}
// Create a site
site, err := fwQ.CreateSite(ctx, fwmod.CreateSiteParams{
SiteID: uuid.New().String(),
WorkspaceID: to.workspace.WorkspaceID,
Domain: "test-" + uuid.New().String()[:8] + ".example.com",
})
if err != nil {
t.Fatalf("create site: %v", err)
}
if site.WorkspaceID != to.workspace.WorkspaceID {
t.Errorf("site workspace mismatch")
}
// Usage should be 1
if usage := getUsage(t, ctx, q, to.pool.PoolID); usage != 1 {
t.Errorf("expected usage=1 after increment, got %d", usage)
}
}
// 10.6 Test grant revocation: revoke grant → limit reduced → site creation blocked at new limit
func TestGrantRevocationBlocksSiteCreation(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
q := entitlements.New(tx)
to := setupTestOrg(t, ctx, tx)
product := createTestProduct(t, ctx, tx, "TestProduct", 5, false)
// Two grants: limit=10
grant1 := createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1)
createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1)
// Use 6 sites (within limit of 10)
for i := 0; i < 6; i++ {
result, err := q.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{
WorkspaceID: to.workspace.WorkspaceID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
t.Fatalf("increment %d: %v", i, err)
}
rows, _ := result.RowsAffected()
if rows != 1 {
t.Fatalf("increment %d: expected 1 row, got %d", i, rows)
}
}
// Revoke first grant and end its conferral → limit drops to 5, but usage is 6
_, err = q.RevokeGrant(ctx, entitlements.RevokeGrantParams{GrantID: grant1.GrantID})
if err != nil {
t.Fatalf("revoke: %v", err)
}
ended, err := q.EndConferral(ctx, entitlements.EndConferralParams{
GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant1.GrantID), Valid: true},
})
if err != nil {
t.Fatalf("end conferral: %v", err)
}
if len(ended) != 1 {
t.Fatalf("expected 1 ended provision, got %d", len(ended))
}
err = entitlements.MaterializePoolEntitlements(ctx, q, to.pool.PoolID)
if err != nil {
t.Fatalf("re-materialize: %v", err)
}
// Limit is now 5, usage is 6 → increment should fail (0 rows)
if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 5 {
t.Errorf("expected limit=5, got %d", limit)
}
result, err := q.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{
WorkspaceID: to.workspace.WorkspaceID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
t.Fatalf("atomic increment: %v", err)
}
rows, _ := result.RowsAffected()
if rows != 0 {
t.Errorf("expected 0 rows (blocked), got %d", rows)
}
}
// 10.7 Test entitlement check: atomic increment rejects when at limit
func TestAtomicIncrementRejectsAtLimit(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
q := entitlements.New(tx)
to := setupTestOrg(t, ctx, tx)
product := createTestProduct(t, ctx, tx, "TestProduct", 5, false)
// Grant gives limit=5
createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1)
// Use all 5
for i := 0; i < 5; i++ {
result, err := q.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{
WorkspaceID: to.workspace.WorkspaceID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
t.Fatalf("increment %d: %v", i, err)
}
rows, _ := result.RowsAffected()
if rows != 1 {
t.Fatalf("increment %d: expected 1 row, got %d", i, rows)
}
}
// 6th increment should fail
result, err := q.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{
WorkspaceID: to.workspace.WorkspaceID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
t.Fatalf("6th increment: %v", err)
}
rows, _ := result.RowsAffected()
if rows != 0 {
t.Errorf("expected 0 rows (at limit), got %d", rows)
}
// Usage should still be 5
if usage := getUsage(t, ctx, q, to.pool.PoolID); usage != 5 {
t.Errorf("expected usage=5, got %d", usage)
}
}
// 10.7b Test clamp: when a downgrade reduces the limit below current usage,
// existing usage is retained and new creation is blocked (the 8c downgrade contract).
func TestAtomicIncrementClampedWhenOverLimit(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
q := entitlements.New(tx)
to := setupTestOrg(t, ctx, tx)
// Member starts on a higher tier (limit 5) and uses 3 sites.
product := createTestProduct(t, ctx, tx, "TestProductStandard", 5, false)
createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1)
for i := 0; i < 3; i++ {
result, err := q.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{
WorkspaceID: to.workspace.WorkspaceID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
t.Fatalf("increment %d: %v", i, err)
}
if rows, _ := result.RowsAffected(); rows != 1 {
t.Fatalf("increment %d: expected 1 row, got %d", i, rows)
}
}
// A rule commit lowers the sites limit below current usage (5 -> 1) and
// the pool is materialized from the committed rule: the same clamp a
// downgrade meets, reached through the rule change.
rule := firstActiveRule(t, ctx, q, product.setID)
if _, err := entitlements.CommitRuleChangeTx(ctx, q, entitlements.CommitRuleChangeInput{
SetID: product.setID,
ChangeKind: entitlements.ChangeKindRuleModified,
RuleID: rule.RuleID,
Rule: entitlements.RuleFields{
RuleType: "limit",
ResourceKey: rule.ResourceKey,
ResourceValue: sql.NullInt64{Int64: 1, Valid: true},
ResourcePerUnit: rule.ResourcePerUnit,
StackingPolicy: rule.StackingPolicy,
},
ActorType: entitlements.ActorTypeSystem,
}); err != nil {
t.Fatalf("lower the limit by rule commit: %v", err)
}
if err := entitlements.MaterializePoolEntitlements(ctx, q, to.pool.PoolID); err != nil {
t.Fatalf("materialize after the rule commit: %v", err)
}
if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 1 {
t.Fatalf("expected limit 1 after the rule commit, got %d", limit)
}
// Over the limit (usage 3 > limit 1): creation must be refused (clamp).
result, err := q.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{
WorkspaceID: to.workspace.WorkspaceID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
t.Fatalf("over-limit increment: %v", err)
}
if rows, _ := result.RowsAffected(); rows != 0 {
t.Errorf("expected 0 rows (clamped over limit), got %d", rows)
}
// Existing usage is retained — nothing is deleted or reset.
if usage := getUsage(t, ctx, q, to.pool.PoolID); usage != 3 {
t.Errorf("expected usage retained at 3, got %d", usage)
}
}
// 10.8 Test site deletion: usage decremented, new site creation succeeds
func TestSiteDeletionDecrementsUsage(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
q := entitlements.New(tx)
fwQ := fwmod.New(tx)
to := setupTestOrg(t, ctx, tx)
product := createTestProduct(t, ctx, tx, "TestProduct", 5, false)
// Grant gives limit=5, use all 5
createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1)
sites := make([]fwmod.Site, 5)
for i := 0; i < 5; i++ {
result, err := q.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{
WorkspaceID: to.workspace.WorkspaceID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
t.Fatalf("increment %d: %v", i, err)
}
rows, _ := result.RowsAffected()
if rows != 1 {
t.Fatalf("increment %d failed", i)
}
sites[i], err = fwQ.CreateSite(ctx, fwmod.CreateSiteParams{
SiteID: uuid.New().String(),
WorkspaceID: to.workspace.WorkspaceID,
Domain: fmt.Sprintf("site-%d-%s.example.com", i, uuid.New().String()[:8]),
})
if err != nil {
t.Fatalf("create site %d: %v", i, err)
}
}
// At limit — increment should fail
result, err := q.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{
WorkspaceID: to.workspace.WorkspaceID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
t.Fatalf("increment at limit: %v", err)
}
rows, _ := result.RowsAffected()
if rows != 0 {
t.Fatalf("expected blocked at limit, got %d rows", rows)
}
// Delete a site and decrement usage
err = fwQ.DeleteSite(ctx, sites[0].SiteID)
if err != nil {
t.Fatalf("delete site: %v", err)
}
_, err = q.AtomicDecrementUsage(ctx, entitlements.AtomicDecrementUsageParams{
WorkspaceID: to.workspace.WorkspaceID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
t.Fatalf("decrement: %v", err)
}
// Usage should be 4, and increment should now succeed
if usage := getUsage(t, ctx, q, to.pool.PoolID); usage != 4 {
t.Errorf("expected usage=4 after delete, got %d", usage)
}
result, err = q.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{
WorkspaceID: to.workspace.WorkspaceID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
t.Fatalf("increment after delete: %v", err)
}
rows, _ = result.RowsAffected()
if rows != 1 {
t.Errorf("expected increment to succeed after delete, got %d rows", rows)
}
}
// 10.9 Test auto-provisioning: new user gets pool and pool assignment
func TestAutoProvisioningCreatesPoolAndAssignment(t *testing.T) {
database := testDB(t)
ctx := context.Background()
claims := provisioning.OIDCClaims{
Subject: "test-" + uuid.New().String(),
Email: fmt.Sprintf("test-%s@example.com", uuid.New().String()[:8]),
EmailVerified: true,
Name: "Auto Test User",
PreferredUsername: "autotest-" + uuid.New().String()[:8],
}
result, err := provisioning.AutoProvision(ctx, database, claims)
if err != nil {
t.Fatalf("auto-provision: %v", err)
}
// Verify pool was created
if result.Pool.PoolID == "" {
t.Error("expected pool to be created")
}
if result.Pool.PoolType != "default" {
t.Errorf("expected pool_type=default, got %s", result.Pool.PoolType)
}
if !result.Pool.IsAutoManaged {
t.Error("expected pool to be auto-managed")
}
// Verify pool assignment was created
if result.PoolAssignment.AssignmentID == "" {
t.Error("expected pool assignment to be created")
}
if result.PoolAssignment.PoolID != result.Pool.PoolID {
t.Error("pool assignment should reference the created pool")
}
if result.PoolAssignment.WorkspaceID != result.Workspace.WorkspaceID {
t.Error("pool assignment should reference the created workspace")
}
if !result.PoolAssignment.IsPrimary {
t.Error("expected pool assignment to be primary")
}
}
// 10.10 Test full migration sequence on fresh database
func TestFullMigrationSequence(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set, skipping integration test")
}
database, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
defer database.Close()
// Run all migrations in dependency order
sources := migrate.Sources()
if err := db.RunMigrations(database, sources); err != nil {
t.Fatalf("migration sequence failed: %v", err)
}
ctx := context.Background()
// Verify core schema structures exist after migrations
// (Products and entitlement sets are created at runtime, not seeded)
// Verify org_types table was seeded with 'personal'
orgQ := organization.New(database)
orgType, err := orgQ.GetOrgType(ctx, "personal")
if err != nil {
t.Fatalf("personal org type not found after migrations: %v", err)
}
if orgType.DisplayName != "Personal" {
t.Errorf("expected display_name='Personal', got %q", orgType.DisplayName)
}
// Verify entitlement tables are queryable
entQ := entitlements.New(database)
_, err = entQ.ListEntitlementSets(ctx)
if err != nil {
t.Fatalf("list entitlement sets: %v", err)
}
}
// deactivateSetRules turns off every active rule of a set in place, the way
// the operator's Remove does once rules are deactivated rather than deleted.
func deactivateSetRules(t *testing.T, ctx context.Context, q *entitlements.Queries, setID string) {
t.Helper()
rules, err := q.GetActiveRulesBySetID(ctx, setID)
if err != nil {
t.Fatalf("list active rules: %v", err)
}
if len(rules) == 0 {
t.Fatalf("set %s has no active rule to deactivate", setID)
}
for _, r := range rules {
if _, err := entitlements.CommitRuleChangeTx(ctx, q, entitlements.CommitRuleChangeInput{
SetID: setID,
ChangeKind: entitlements.ChangeKindRuleDeactivated,
RuleID: r.RuleID,
ActorType: entitlements.ActorTypeSystem,
}); err != nil {
t.Fatalf("deactivate rule %s: %v", r.RuleID, err)
}
}
}
// Scenario: the set's only limit rule is deactivated under an active
// provision. The limit reaches 0, the orphaned contribution rows go, and
// the entitlement row and the usage row survive at the recorded usage.
func TestMaterializeLastRuleDeactivatedZeroesLimit(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
q := entitlements.New(tx)
to := setupTestOrg(t, ctx, tx)
product := createTestProduct(t, ctx, tx, "RuleRemoval", 5, false)
createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1)
if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 5 {
t.Fatalf("expected limit=5 before deactivation, got %d", limit)
}
if _, err := tx.ExecContext(ctx,
`UPDATE core.numeric_entitlement_usage SET current_usage = 2
WHERE pool_id = $1 AND resource_key = 'fedwiki_sites'`,
to.pool.PoolID,
); err != nil {
t.Fatalf("record usage: %v", err)
}
deactivateSetRules(t, ctx, q, product.setID)
if err := entitlements.MaterializePoolEntitlements(ctx, q, to.pool.PoolID); err != nil {
t.Fatalf("materialize after deactivation: %v", err)
}
if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 0 {
t.Errorf("expected limit=0 after the last rule was deactivated, got %d", limit)
}
if usage := getUsage(t, ctx, q, to.pool.PoolID); usage != 2 {
t.Errorf("expected usage row retained at 2, got %d", usage)
}
ent, err := q.GetNumericEntitlementByPoolAndResource(ctx, entitlements.GetNumericEntitlementByPoolAndResourceParams{
PoolID: to.pool.PoolID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
t.Fatalf("expected entitlement row retained, got error: %v", err)
}
contribs, err := q.ListContributionsByEntitlementID(ctx, ent.EntitlementID)
if err != nil {
t.Fatalf("list contributions: %v", err)
}
if len(contribs) != 0 {
t.Errorf("expected no contribution rows after the last rule was deactivated, got %d", len(contribs))
}
}
// Scenario: a boolean key whose only rule is deactivated under an active
// provision lapses to granted=false with its row retained.
func TestBooleanLastRuleDeactivatedLapses(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
q := entitlements.New(tx)
to := setupTestOrg(t, ctx, tx)
key := "test.bool-" + uuid.New().String()[:8]
createBoolResourceKey(t, ctx, tx, key)
product := createBoolTestProduct(t, ctx, tx, "BoolRuleRemoval", key)
createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1)
if be, err := getBooleanEntitlement(t, ctx, q, to.pool.PoolID, key); err != nil || !be.Granted {
t.Fatalf("expected granted=true before deactivation, got granted=%v err=%v", be.Granted, err)
}
deactivateSetRules(t, ctx, q, product.setID)
if err := entitlements.MaterializePoolEntitlements(ctx, q, to.pool.PoolID); err != nil {
t.Fatalf("materialize after deactivation: %v", err)
}
be, err := getBooleanEntitlement(t, ctx, q, to.pool.PoolID, key)
if err != nil {
t.Fatalf("expected retained row after the last rule was deactivated, got error: %v", err)
}
if be.Granted {
t.Errorf("expected granted=false after the last rule was deactivated, got true")
}
}
// Scenario: one provision funds two resource keys. Rebuilding one key's
// contributions must not delete the provision's row on the other key, so
// both rows are present after a materialization over existing rows.
func TestMaterializeProvisionFundingTwoKeysKeepsBothContributions(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
q := entitlements.New(tx)
to := setupTestOrg(t, ctx, tx)
// Sorts before fedwiki_sites, so it is rebuilt first
secondKey := "alpha.num-" + uuid.New().String()[:8]
if _, err := tx.ExecContext(ctx,
`INSERT INTO core.resource_keys (resource_key, display_name, unit)
VALUES ($1, $2, 'items')`,
secondKey, "Test Numeric "+secondKey,
); err != nil {
t.Fatalf("create resource key %s: %v", secondKey, err)
}
product := createTestProduct(t, ctx, tx, "TwoKeys", 5, false)
addSetRule(t, ctx, q, product.setID, entitlements.RuleFields{
RuleType: "limit",
ResourceKey: sql.NullString{String: secondKey, Valid: true},
ResourceValue: sql.NullInt64{Int64: 3, Valid: true},
ResourcePerUnit: sql.NullBool{Bool: false, Valid: true},
StackingPolicy: sql.NullString{String: "additive", Valid: true},
})
createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1)
// Second run rebuilds over the rows the first run wrote
if err := entitlements.MaterializePoolEntitlements(ctx, q, to.pool.PoolID); err != nil {
t.Fatalf("materialize over existing rows: %v", err)
}
for key, wantLimit := range map[string]int64{"fedwiki_sites": 5, secondKey: 3} {
ent, err := q.GetNumericEntitlementByPoolAndResource(ctx, entitlements.GetNumericEntitlementByPoolAndResourceParams{
PoolID: to.pool.PoolID,
ResourceKey: key,
})
if err != nil {
t.Fatalf("get entitlement for %s: %v", key, err)
}
if ent.ResourceLimit != wantLimit {
t.Errorf("expected limit=%d for %s, got %d", wantLimit, key, ent.ResourceLimit)
}
contribs, err := q.ListContributionsByEntitlementID(ctx, ent.EntitlementID)
if err != nil {
t.Fatalf("list contributions for %s: %v", key, err)
}
if len(contribs) != 1 {
t.Errorf("expected exactly one contribution row for %s, got %d", key, len(contribs))
}
}
}
// snapshotPoolRows renders every row a pool owns in the four materialized
// tables and in the two ledger tables keyed by pool as text, so a dry run or
// a rolled-back commit can be shown to leave them byte-identical.
func snapshotPoolRows(t *testing.T, ctx context.Context, tx *sql.Tx, poolID string) string {
t.Helper()
queries := []string{
`SELECT coalesce(string_agg(n::text, '|' ORDER BY n.resource_key), '')
FROM core.numeric_entitlements n WHERE n.pool_id = $1`,
`SELECT coalesce(string_agg(c::text, '|' ORDER BY c.entitlement_id, c.provision_id), '')
FROM core.numeric_entitlement_contributions c
JOIN core.numeric_entitlements n ON n.entitlement_id = c.entitlement_id
WHERE n.pool_id = $1`,
`SELECT coalesce(string_agg(u::text, '|' ORDER BY u.resource_key), '')
FROM core.numeric_entitlement_usage u WHERE u.pool_id = $1`,
`SELECT coalesce(string_agg(b::text, '|' ORDER BY b.resource_key), '')
FROM core.boolean_entitlements b WHERE b.pool_id = $1`,
`SELECT coalesce(string_agg(o::text, '|' ORDER BY o.change_id), '')
FROM core.entitlement_set_change_obligations o WHERE o.pool_id = $1`,
`SELECT coalesce(string_agg(e::text, '|' ORDER BY e.effect_id), '')
FROM core.entitlement_set_change_effects e WHERE e.pool_id = $1`,
}
var out string
for _, query := range queries {
var rows string
if err := tx.QueryRowContext(ctx, query, poolID).Scan(&rows); err != nil {
t.Fatalf("snapshot rows: %v", err)
}
out += rows + "\n"
}
return out
}
func stateFor(t *testing.T, states []entitlements.KeyState, key string) entitlements.KeyState {
t.Helper()
for _, st := range states {
if st.ResourceKey == key {
return st
}
}
t.Fatalf("no would-be state for %s in %+v", key, states)
return entitlements.KeyState{}
}
func firstActiveRule(t *testing.T, ctx context.Context, q *entitlements.Queries, setID string) entitlements.EntitlementSetRule {
t.Helper()
rules, err := q.GetActiveRulesBySetID(ctx, setID)
if err != nil || len(rules) == 0 {
t.Fatalf("active rules for %s: %v (%d)", setID, err, len(rules))
}
return rules[0]
}
// Scenario: the compute step with no overlay returns exactly what the apply
// step then writes, for a fed numeric key, a numeric key falling to 0 and a
// boolean key.
func TestDryRunMatchesApply(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
q := entitlements.New(tx)
to := setupTestOrg(t, ctx, tx)
secondKey := "alpha.num-" + uuid.New().String()[:8]
if _, err := tx.ExecContext(ctx,
`INSERT INTO core.resource_keys (resource_key, display_name, unit) VALUES ($1, $2, 'items')`,
secondKey, "Test Numeric "+secondKey,
); err != nil {
t.Fatalf("create resource key: %v", err)
}
boolKey := "test.bool-" + uuid.New().String()[:8]
createBoolResourceKey(t, ctx, tx, boolKey)
numeric := createTestProduct(t, ctx, tx, "DryRunNumeric", 5, false)
second := createTestProduct(t, ctx, tx, "DryRunSecond", 3, false)
boolean := createBoolTestProduct(t, ctx, tx, "DryRunBool", boolKey)
// Point the second product's set at the second key: a rule's resource key
// is not editable, so the fedwiki_sites rule is deactivated and a rule for
// the second key added in its place.
deactivateSetRules(t, ctx, q, second.setID)
addSetRule(t, ctx, q, second.setID, entitlements.RuleFields{
RuleType: "limit",
ResourceKey: sql.NullString{String: secondKey, Valid: true},
ResourceValue: sql.NullInt64{Int64: 3, Valid: true},
ResourcePerUnit: sql.NullBool{Bool: false, Valid: true},
StackingPolicy: sql.NullString{String: "additive", Valid: true},
})
createGrantAndProvision(t, ctx, tx, q, to, numeric.productID, 1)
createGrantAndProvision(t, ctx, tx, q, to, second.productID, 1)
createGrantAndProvision(t, ctx, tx, q, to, boolean.productID, 1)
// The second key loses its only rule, so the fold no longer feeds it
deactivateSetRules(t, ctx, q, second.setID)
states, err := entitlements.DryRunPoolEntitlements(ctx, q, to.pool.PoolID, nil)
if err != nil {
t.Fatalf("dry run: %v", err)
}
if err := entitlements.MaterializePoolEntitlements(ctx, q, to.pool.PoolID); err != nil {
t.Fatalf("materialize: %v", err)
}
if len(states) != 3 {
t.Fatalf("expected 3 would-be states, got %d: %+v", len(states), states)
}
for _, st := range states {
if st.Boolean {
be, err := getBooleanEntitlement(t, ctx, q, to.pool.PoolID, st.ResourceKey)
if err != nil {
t.Fatalf("boolean row for %s: %v", st.ResourceKey, err)
}
if be.Granted != st.IsEnabled {
t.Errorf("%s: dry run said enabled=%v, apply wrote granted=%v", st.ResourceKey, st.IsEnabled, be.Granted)
}
continue
}
ent, err := q.GetNumericEntitlementByPoolAndResource(ctx, entitlements.GetNumericEntitlementByPoolAndResourceParams{
PoolID: to.pool.PoolID,
ResourceKey: st.ResourceKey,
})
if err != nil {
t.Fatalf("numeric row for %s: %v", st.ResourceKey, err)
}
if ent.ResourceLimit != st.ResourceLimit {
t.Errorf("%s: dry run said limit=%d, apply wrote %d", st.ResourceKey, st.ResourceLimit, ent.ResourceLimit)
}
}
if st := stateFor(t, states, "fedwiki_sites"); st.ResourceLimit != 5 {
t.Errorf("expected fedwiki_sites would-be 5, got %d", st.ResourceLimit)
}
if st := stateFor(t, states, secondKey); st.ResourceLimit != 0 {
t.Errorf("expected %s would-be 0 after its rule went, got %d", secondKey, st.ResourceLimit)
}
if st := stateFor(t, states, boolKey); !st.IsEnabled {
t.Errorf("expected %s would-be enabled", boolKey)
}
}
// Scenario: overlays that raise, lower, add and deactivate a rule each
// return the would-be state, and none of them writes a byte.
func TestDryRunOverlaysWriteNothing(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
q := entitlements.New(tx)
to := setupTestOrg(t, ctx, tx)
product := createTestProduct(t, ctx, tx, "OverlayProduct", 5, false)
createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1)
rule := firstActiveRule(t, ctx, q, product.setID)
before := snapshotPoolRows(t, ctx, tx, to.pool.PoolID)
limitOverlay := func(value int64) entitlements.RuleOverlay {
id := rule.RuleID
return entitlements.RuleOverlay{
RuleID: &id,
SetID: product.setID,
RuleType: "limit",
ResourceKey: sql.NullString{String: "fedwiki_sites", Valid: true},
ResourceValue: sql.NullInt64{Int64: value, Valid: true},
ResourcePerUnit: sql.NullBool{Bool: false, Valid: true},
StackingPolicy: sql.NullString{String: "additive", Valid: true},
IsActive: true,
}
}
added := entitlements.RuleOverlay{
SetID: product.setID,
RuleType: "limit",
ResourceKey: sql.NullString{String: "alpha.added", Valid: true},
ResourceValue: sql.NullInt64{Int64: 3, Valid: true},
ResourcePerUnit: sql.NullBool{Bool: false, Valid: true},
StackingPolicy: sql.NullString{String: "additive", Valid: true},
IsActive: true,
}
deactivated := limitOverlay(5)
deactivated.IsActive = false
cases := []struct {
name string
overlays []entitlements.RuleOverlay
key string
want int64
}{
{"raise", []entitlements.RuleOverlay{limitOverlay(8)}, "fedwiki_sites", 8},
{"lower", []entitlements.RuleOverlay{limitOverlay(2)}, "fedwiki_sites", 2},
{"add", []entitlements.RuleOverlay{added}, "alpha.added", 3},
{"deactivate", []entitlements.RuleOverlay{deactivated}, "fedwiki_sites", 0},
// A batch: the edited rule and the added one folded together, one
// overlay per resource key.
{"batch", []entitlements.RuleOverlay{limitOverlay(2), added}, "alpha.added", 3},
}
for _, tc := range cases {
states, err := entitlements.DryRunPoolEntitlements(ctx, q, to.pool.PoolID, tc.overlays)
if err != nil {
t.Fatalf("%s: dry run: %v", tc.name, err)
}
if got := stateFor(t, states, tc.key).ResourceLimit; got != tc.want {
t.Errorf("%s: expected %s would-be %d, got %d", tc.name, tc.key, tc.want, got)
}
if tc.name == "add" {
if got := stateFor(t, states, "fedwiki_sites").ResourceLimit; got != 5 {
t.Errorf("add: expected fedwiki_sites unchanged at 5, got %d", got)
}
}
if tc.name == "batch" {
if got := stateFor(t, states, "fedwiki_sites").ResourceLimit; got != 2 {
t.Errorf("batch: expected fedwiki_sites at the edited 2, got %d", got)
}
}
}
if after := snapshotPoolRows(t, ctx, tx, to.pool.PoolID); after != before {
t.Errorf("dry runs changed rows:\nbefore:\n%s\nafter:\n%s", before, after)
}
if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 5 {
t.Errorf("expected stored limit still 5 after dry runs, got %d", limit)
}
}