Files
member-console/internal/entitlements/materialize_test.go
Christian Galo 667e9ffe24 Add plan ladders and pool provision transitions
Introduce DB migrations for ladder and pool-attachment tables and an
audit log for provision transitions. Make product_type nullable and add
lifecycle_status plus a product_kinds view. Implement Transition and
ReapplyDefaultsForPool primitives, SQLC queries/models, webhook and
Temporal workflow integration, and accompanying unit/integration tests.
2026-04-19 20:45:56 -05:00

919 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"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/fedwiki"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
"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 := append(db.BaseSources(),
identity.MigrationSource(),
organization.MigrationSource(),
billing.MigrationSource(),
entitlements.MigrationSource(),
fwmod.MigrationSource(),
)
if err := db.RunMigrations(database, sources); err != nil {
t.Fatalf("failed to run migrations: %v", err)
}
t.Cleanup(func() { database.Close() })
return database
}
// resolveEntitlementSetID looks up a product's entitlement_set_id for test use.
func resolveEntitlementSetID(t *testing.T, ctx context.Context, tx *sql.Tx, productID string) string {
t.Helper()
var setID sql.NullString
err := tx.QueryRowContext(ctx,
"SELECT entitlement_set_id FROM billing.products WHERE product_id = $1",
productID,
).Scan(&setID)
if err != nil {
t.Fatalf("resolve entitlement_set_id for product %s: %v", productID, err)
}
if !setID.Valid {
t.Fatalf("product %s has no entitlement_set_id", productID)
}
return setID.String
}
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, "TestFedWiki", 5, false)
// Create a grant
grant, err := q.CreateGrant(ctx, entitlements.CreateGrantParams{
ProductID: uuid.NullUUID{
UUID: uuid.MustParse(tp.productID),
Valid: true,
},
GrantedToOrgID: uuid.NullUUID{
UUID: uuid.MustParse(org.OrgID),
Valid: true,
},
GrantedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(person.PersonID), Valid: true},
GrantReason: "promotional",
Quantity: 1,
})
if err != nil {
t.Fatalf("create grant: %v", err)
}
// Create provision
_, err = q.CreatePoolProvision(ctx, entitlements.CreatePoolProvisionParams{
PoolID: pool.PoolID,
GrantID: uuid.NullUUID{
UUID: uuid.MustParse(grant.GrantID),
Valid: true,
},
EntitlementSetID: tp.setID,
Quantity: 1,
})
if err != nil {
t.Fatalf("create provision: %v", err)
}
// 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: "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: "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 "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: "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)
}
// product_type is NULL per Doc 31 Amendment #3 — tests that want to assert
// plan-ness do so via plan_ladder_tiers membership, not the label column.
var productID string
err = tx.QueryRowContext(ctx,
`INSERT INTO billing.products (name, product_type, is_active, is_public, entitlement_set_id)
VALUES ($1, NULL, TRUE, TRUE, $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 creates a grant + provision for the given product and quantity, 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()
setID := resolveEntitlementSetID(t, ctx, tx, productID)
grant, err := q.CreateGrant(ctx, entitlements.CreateGrantParams{
ProductID: uuid.NullUUID{
UUID: uuid.MustParse(productID),
Valid: true,
},
GrantedToOrgID: uuid.NullUUID{
UUID: uuid.MustParse(to.org.OrgID),
Valid: true,
},
GrantedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(to.person.PersonID), Valid: true},
GrantReason: "test",
Quantity: quantity,
})
if err != nil {
t.Fatalf("create grant: %v", err)
}
_, err = q.CreatePoolProvision(ctx, entitlements.CreatePoolProvisionParams{
PoolID: to.pool.PoolID,
GrantID: uuid.NullUUID{
UUID: uuid.MustParse(grant.GrantID),
Valid: true,
},
EntitlementSetID: setID,
Quantity: quantity,
})
if err != nil {
t.Fatalf("create provision: %v", err)
}
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: "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: "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, "TestFedWiki", 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, "TestFedWiki", 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, end its provision, re-materialize
_, err = q.RevokeGrant(ctx, entitlements.RevokeGrantParams{
GrantID: grant1.GrantID,
})
if err != nil {
t.Fatalf("revoke grant: %v", err)
}
prov, err := q.GetPoolProvisionByGrantID(ctx, uuid.NullUUID{
UUID: uuid.MustParse(grant1.GrantID),
Valid: true,
})
if err != nil {
t.Fatalf("get provision: %v", err)
}
_, err = q.UpdatePoolProvisionStatus(ctx, entitlements.UpdatePoolProvisionStatusParams{
ProvisionID: prov.ProvisionID,
Status: "ended",
})
if err != nil {
t.Fatalf("end provision: %v", err)
}
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, "TestFedWiki", 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, "TestFedWiki", 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: "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{
WorkspaceID: to.workspace.WorkspaceID,
Domain: "test-" + uuid.New().String()[:8] + ".wiki.cafe",
IsCustomDomain: false,
})
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, "TestFedWiki", 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: "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 → 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)
}
prov, err := q.GetPoolProvisionByGrantID(ctx, uuid.NullUUID{UUID: uuid.MustParse(grant1.GrantID), Valid: true})
if err != nil {
t.Fatalf("get provision: %v", err)
}
_, err = q.UpdatePoolProvisionStatus(ctx, entitlements.UpdatePoolProvisionStatusParams{
ProvisionID: prov.ProvisionID,
Status: "ended",
})
if err != nil {
t.Fatalf("end provision: %v", err)
}
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: "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, "TestFedWiki", 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: "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: "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.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, "TestFedWiki", 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: "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{
WorkspaceID: to.workspace.WorkspaceID,
Domain: fmt.Sprintf("site-%d-%s.wiki.cafe", i, uuid.New().String()[:8]),
IsCustomDomain: false,
})
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: "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: "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: "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 := append(db.BaseSources(),
identity.MigrationSource(),
organization.MigrationSource(),
billing.MigrationSource(),
entitlements.MigrationSource(),
fwmod.MigrationSource(),
)
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)
}
}
// Test direct entitlement set grant (no product)
func TestDirectEntitlementSetGrant(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)
// Create a custom entitlement set
set, err := q.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "Board Grant — Extended Storage",
IsActive: true,
})
if err != nil {
t.Fatalf("create entitlement set: %v", err)
}
// Add a rule to the set
_, err = q.CreateEntitlementSetRule(ctx, entitlements.CreateEntitlementSetRuleParams{
SetID: set.SetID,
RuleType: "limit",
ResourceKey: sql.NullString{String: "sites", Valid: true},
ResourceValue: sql.NullInt64{Int64: 10, Valid: true},
ResourcePerUnit: sql.NullBool{Bool: false, Valid: true},
StackingPolicy: sql.NullString{String: "additive", Valid: true},
})
if err != nil {
t.Fatalf("create set rule: %v", err)
}
// Create a grant with direct entitlement set (no product)
grant, err := q.CreateGrant(ctx, entitlements.CreateGrantParams{
EntitlementSetID: uuid.NullUUID{
UUID: uuid.MustParse(set.SetID),
Valid: true,
},
GrantedToOrgID: uuid.NullUUID{
UUID: uuid.MustParse(to.org.OrgID),
Valid: true,
},
GrantedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(to.person.PersonID), Valid: true},
GrantReason: "board_decision",
Quantity: 1,
})
if err != nil {
t.Fatalf("create grant: %v", err)
}
// Create provision with the entitlement set
_, err = q.CreatePoolProvision(ctx, entitlements.CreatePoolProvisionParams{
PoolID: to.pool.PoolID,
GrantID: uuid.NullUUID{
UUID: uuid.MustParse(grant.GrantID),
Valid: true,
},
EntitlementSetID: set.SetID,
Quantity: 1,
})
if err != nil {
t.Fatalf("create provision: %v", err)
}
// Materialize
err = entitlements.MaterializePoolEntitlements(ctx, q, to.pool.PoolID)
if err != nil {
t.Fatalf("materialize: %v", err)
}
// Should have limit=10 from the direct set
if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 10 {
t.Errorf("expected limit=10 from direct set grant, got %d", limit)
}
}