Files
member-console/internal/entitlements/materialize_test.go
T
cgalo5758 dd3962990b Adopt entity keys and add invoice numbers
Replace the entity slugs on organizations, workspaces, resource pools,
and
plan ladders with nullable `key` columns and add keys to products,
prices,
and entitlement sets. Rename `providers.slug` to `provider` and add
partial
unique indexes for system and org role names.

Assign invoice numbers per billing account from a gapless transactional
counter; Stripe's number moves to the invoice mapping as an external
reference.

Seeds, fixtures, and the operator lookup address rows by key, and the
returning-login resync no longer blanks a display name when the IdP
sends
no `name` claim.
2026-08-29 20:12:04 -05:00

854 lines
25 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",
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}
}
// 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)
}
}