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.
919 lines
26 KiB
Go
919 lines
26 KiB
Go
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)
|
||
}
|
||
}
|