Files
member-console/internal/entitlements/materialize_test.go
T
cgalo5758 8d05934e93 Add domains registry with claims and placements
Domain names become an allocatable resource with one authority. A new
core module (schema `domains`, own migration stream between core and the
integrations) owns claims — a DNS node plus its whole subtree, mutually
disjoint: operator shared-domain roots, member claims carved from them,
and bring-your-own names proven by TXT verification — and placements,
which bind a name inside a claim to a provider slug and resource ref.

Verification moves to the claim and decouples from creation. A member
proves control of a domain once; afterwards every name inside it places
instantly, wildcard-CNAME friendly, with no further DNS work. The claim
workflow activates the claim and stops — it no longer creates a site —
so the sites list offers a one-click create once a domain verifies.

/domains/ask answers from placements and is registered by core rather
than the FedWiki adapter; its HTTP contract is unchanged. A configured
`domains-ask-fallback-url` forwards names the registry does not know to
a legacy answerer, the strangler seam wiki.cafe's migration needs; a
name the registry knows but has archived is refused locally.

FedWiki's create saga reserves the name before the farm call, carrying a
workflow-minted site id so retries are idempotent, and compensates on
failure. Sync places only names it owns, never stealing a member's;
lifecycle transitions and the retention purge maintain servability. An
unconditional boot pass seeds operator roots, releases orphaned
placements, and adopts pre-existing sites — grandfathering member-owned
external domains shortest-name-first, and skipping name policy, so a
live single-letter site cannot lose its certificate.

Members manage domains at /domains: claims with verification status, DNS
records including an optional wildcard row, check-now, cancel, release.
Name policy (reserved, blocked, premium, plus a single-letter guard) is
operator data; refusals collapse to a plain "unavailable" so the console
never becomes an oracle for who holds what.

BREAKING (pre-release): `fedwiki.custom_domain_verifications` and
`sites.is_custom_domain` are dropped, the flag now derived from the
placement's claim kind; resource key `fedwiki_custom_domains` migrates
to the platform-owned `external_domain_claims`; running
verify-custom-domain workflows must be terminated before deploy.
2026-07-24 21:25:40 -05:00

859 lines
26 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.
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 := database.BeginTx(ctx, nil)
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",
Slug: "test-org-" + uuid.New().String()[:8],
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",
Slug: "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",
Slug: "test-org-" + uuid.New().String()[:8],
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",
Slug: "default",
})
if err != nil {
t.Fatalf("create workspace: %v", err)
}
pool, err := q.CreateResourcePool(ctx, entitlements.CreateResourcePoolParams{
OrgID: org.OrgID,
Name: "Default",
Slug: "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}
}
// 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)
}
_, err = q.CreateEntitlementSetRule(ctx, entitlements.CreateEntitlementSetRuleParams{
SetID: set.SetID,
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},
})
if err != nil {
t.Fatalf("create set rule for %s: %v", name, err)
}
// 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 := database.BeginTx(ctx, nil)
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 := database.BeginTx(ctx, nil)
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 := database.BeginTx(ctx, nil)
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 := database.BeginTx(ctx, nil)
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 := database.BeginTx(ctx, nil)
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 := database.BeginTx(ctx, nil)
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 := database.BeginTx(ctx, nil)
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)
}
}
// Downgrade reduces the sites limit below current usage (5 -> 1).
if _, err := tx.ExecContext(ctx,
"UPDATE core.numeric_entitlements SET resource_limit = 1 WHERE pool_id = $1 AND resource_key = 'fedwiki_sites'",
to.pool.PoolID,
); err != nil {
t.Fatalf("reduce limit: %v", err)
}
// 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 := database.BeginTx(ctx, nil)
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)
}
}