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