The Rules section is one record table grouped by kind, Limit then Boolean, on fixed columns, edited in place: Edit opens a row's controls in their columns, Add rule opens a dense row above the table, and every change is staged into a tray that lists the deltas with Undo and applies them as one rule-change act. The reduction policy is a column of the rule beside its limit. History shows counts only. Group rows are a quiet heading rather than a divider, the maintainer's pick from four rounds of outside-model ideation. Dense rows align to the top and render each error under its control in every form family (design D16), replacing the below-row error block; the forms library gains the batch form (rows plus one tray) and the RowField dense and label-hidden options. Migration 00019 records the governing reduction policy on effect rows. Archive staged-rule-changes with its spec updates (entitlement-set- management, entitlement-set-history, entitlements, form-library, form-conventions, ui-quality-gate). Screens accepted 2026-09-19.
1285 lines
42 KiB
Go
1285 lines
42 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||
|
||
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 := entitlements.BeginRuleChange(ctx, database)
|
||
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}
|
||
}
|
||
|
||
// addSetRule files one rule through core.commit_rule_change, the only write
|
||
// path to core.entitlement_set_rules since migration 00018 revoked the DML,
|
||
// and returns the rule id it wrote. The caller's transaction must hold the
|
||
// exclusive materialization rendezvous, so it comes from BeginRuleChange.
|
||
func addSetRule(t *testing.T, ctx context.Context, q *entitlements.Queries, setID string, rule entitlements.RuleFields) string {
|
||
t.Helper()
|
||
row, err := entitlements.CommitRuleChangeTx(ctx, q, entitlements.CommitRuleChangeInput{
|
||
SetID: setID,
|
||
ChangeKind: entitlements.ChangeKindRuleAdded,
|
||
Rule: rule,
|
||
ActorType: entitlements.ActorTypeSystem,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("add rule to set %s: %v", setID, err)
|
||
}
|
||
return row.RuleID
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
addSetRule(t, ctx, q, set.SetID, entitlements.RuleFields{
|
||
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},
|
||
})
|
||
|
||
// 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 := entitlements.BeginRuleChange(ctx, database)
|
||
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 := entitlements.BeginRuleChange(ctx, database)
|
||
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 := entitlements.BeginRuleChange(ctx, database)
|
||
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 := entitlements.BeginRuleChange(ctx, database)
|
||
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 := entitlements.BeginRuleChange(ctx, database)
|
||
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 := entitlements.BeginRuleChange(ctx, database)
|
||
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 := entitlements.BeginRuleChange(ctx, database)
|
||
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)
|
||
}
|
||
}
|
||
|
||
// A rule commit lowers the sites limit below current usage (5 -> 1) and
|
||
// the pool is materialized from the committed rule: the same clamp a
|
||
// downgrade meets, reached through the rule change.
|
||
rule := firstActiveRule(t, ctx, q, product.setID)
|
||
if _, err := entitlements.CommitRuleChangeTx(ctx, q, entitlements.CommitRuleChangeInput{
|
||
SetID: product.setID,
|
||
ChangeKind: entitlements.ChangeKindRuleModified,
|
||
RuleID: rule.RuleID,
|
||
Rule: entitlements.RuleFields{
|
||
RuleType: "limit",
|
||
ResourceKey: rule.ResourceKey,
|
||
ResourceValue: sql.NullInt64{Int64: 1, Valid: true},
|
||
ResourcePerUnit: rule.ResourcePerUnit,
|
||
StackingPolicy: rule.StackingPolicy,
|
||
},
|
||
ActorType: entitlements.ActorTypeSystem,
|
||
}); err != nil {
|
||
t.Fatalf("lower the limit by rule commit: %v", err)
|
||
}
|
||
if err := entitlements.MaterializePoolEntitlements(ctx, q, to.pool.PoolID); err != nil {
|
||
t.Fatalf("materialize after the rule commit: %v", err)
|
||
}
|
||
if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 1 {
|
||
t.Fatalf("expected limit 1 after the rule commit, got %d", limit)
|
||
}
|
||
|
||
// 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 := entitlements.BeginRuleChange(ctx, database)
|
||
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)
|
||
}
|
||
}
|
||
|
||
// deactivateSetRules turns off every active rule of a set in place, the way
|
||
// the operator's Remove does once rules are deactivated rather than deleted.
|
||
func deactivateSetRules(t *testing.T, ctx context.Context, q *entitlements.Queries, setID string) {
|
||
t.Helper()
|
||
rules, err := q.GetActiveRulesBySetID(ctx, setID)
|
||
if err != nil {
|
||
t.Fatalf("list active rules: %v", err)
|
||
}
|
||
if len(rules) == 0 {
|
||
t.Fatalf("set %s has no active rule to deactivate", setID)
|
||
}
|
||
for _, r := range rules {
|
||
if _, err := entitlements.CommitRuleChangeTx(ctx, q, entitlements.CommitRuleChangeInput{
|
||
SetID: setID,
|
||
ChangeKind: entitlements.ChangeKindRuleDeactivated,
|
||
RuleID: r.RuleID,
|
||
ActorType: entitlements.ActorTypeSystem,
|
||
}); err != nil {
|
||
t.Fatalf("deactivate rule %s: %v", r.RuleID, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Scenario: the set's only limit rule is deactivated under an active
|
||
// provision. The limit reaches 0, the orphaned contribution rows go, and
|
||
// the entitlement row and the usage row survive at the recorded usage.
|
||
func TestMaterializeLastRuleDeactivatedZeroesLimit(t *testing.T) {
|
||
database := testDB(t)
|
||
ctx := context.Background()
|
||
tx, err := entitlements.BeginRuleChange(ctx, database)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
q := entitlements.New(tx)
|
||
to := setupTestOrg(t, ctx, tx)
|
||
product := createTestProduct(t, ctx, tx, "RuleRemoval", 5, false)
|
||
createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1)
|
||
if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 5 {
|
||
t.Fatalf("expected limit=5 before deactivation, got %d", limit)
|
||
}
|
||
if _, err := tx.ExecContext(ctx,
|
||
`UPDATE core.numeric_entitlement_usage SET current_usage = 2
|
||
WHERE pool_id = $1 AND resource_key = 'fedwiki_sites'`,
|
||
to.pool.PoolID,
|
||
); err != nil {
|
||
t.Fatalf("record usage: %v", err)
|
||
}
|
||
|
||
deactivateSetRules(t, ctx, q, product.setID)
|
||
if err := entitlements.MaterializePoolEntitlements(ctx, q, to.pool.PoolID); err != nil {
|
||
t.Fatalf("materialize after deactivation: %v", err)
|
||
}
|
||
|
||
if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 0 {
|
||
t.Errorf("expected limit=0 after the last rule was deactivated, got %d", limit)
|
||
}
|
||
if usage := getUsage(t, ctx, q, to.pool.PoolID); usage != 2 {
|
||
t.Errorf("expected usage row retained at 2, got %d", usage)
|
||
}
|
||
ent, err := q.GetNumericEntitlementByPoolAndResource(ctx, entitlements.GetNumericEntitlementByPoolAndResourceParams{
|
||
PoolID: to.pool.PoolID,
|
||
ResourceKey: "fedwiki_sites",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("expected entitlement row retained, got error: %v", err)
|
||
}
|
||
contribs, err := q.ListContributionsByEntitlementID(ctx, ent.EntitlementID)
|
||
if err != nil {
|
||
t.Fatalf("list contributions: %v", err)
|
||
}
|
||
if len(contribs) != 0 {
|
||
t.Errorf("expected no contribution rows after the last rule was deactivated, got %d", len(contribs))
|
||
}
|
||
}
|
||
|
||
// Scenario: a boolean key whose only rule is deactivated under an active
|
||
// provision lapses to granted=false with its row retained.
|
||
func TestBooleanLastRuleDeactivatedLapses(t *testing.T) {
|
||
database := testDB(t)
|
||
ctx := context.Background()
|
||
tx, err := entitlements.BeginRuleChange(ctx, database)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
q := entitlements.New(tx)
|
||
to := setupTestOrg(t, ctx, tx)
|
||
key := "test.bool-" + uuid.New().String()[:8]
|
||
createBoolResourceKey(t, ctx, tx, key)
|
||
product := createBoolTestProduct(t, ctx, tx, "BoolRuleRemoval", key)
|
||
createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1)
|
||
if be, err := getBooleanEntitlement(t, ctx, q, to.pool.PoolID, key); err != nil || !be.Granted {
|
||
t.Fatalf("expected granted=true before deactivation, got granted=%v err=%v", be.Granted, err)
|
||
}
|
||
|
||
deactivateSetRules(t, ctx, q, product.setID)
|
||
if err := entitlements.MaterializePoolEntitlements(ctx, q, to.pool.PoolID); err != nil {
|
||
t.Fatalf("materialize after deactivation: %v", err)
|
||
}
|
||
|
||
be, err := getBooleanEntitlement(t, ctx, q, to.pool.PoolID, key)
|
||
if err != nil {
|
||
t.Fatalf("expected retained row after the last rule was deactivated, got error: %v", err)
|
||
}
|
||
if be.Granted {
|
||
t.Errorf("expected granted=false after the last rule was deactivated, got true")
|
||
}
|
||
}
|
||
|
||
// Scenario: one provision funds two resource keys. Rebuilding one key's
|
||
// contributions must not delete the provision's row on the other key, so
|
||
// both rows are present after a materialization over existing rows.
|
||
func TestMaterializeProvisionFundingTwoKeysKeepsBothContributions(t *testing.T) {
|
||
database := testDB(t)
|
||
ctx := context.Background()
|
||
tx, err := entitlements.BeginRuleChange(ctx, database)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
q := entitlements.New(tx)
|
||
to := setupTestOrg(t, ctx, tx)
|
||
// Sorts before fedwiki_sites, so it is rebuilt first
|
||
secondKey := "alpha.num-" + uuid.New().String()[:8]
|
||
if _, err := tx.ExecContext(ctx,
|
||
`INSERT INTO core.resource_keys (resource_key, display_name, unit)
|
||
VALUES ($1, $2, 'items')`,
|
||
secondKey, "Test Numeric "+secondKey,
|
||
); err != nil {
|
||
t.Fatalf("create resource key %s: %v", secondKey, err)
|
||
}
|
||
product := createTestProduct(t, ctx, tx, "TwoKeys", 5, false)
|
||
addSetRule(t, ctx, q, product.setID, entitlements.RuleFields{
|
||
RuleType: "limit",
|
||
ResourceKey: sql.NullString{String: secondKey, Valid: true},
|
||
ResourceValue: sql.NullInt64{Int64: 3, Valid: true},
|
||
ResourcePerUnit: sql.NullBool{Bool: false, Valid: true},
|
||
StackingPolicy: sql.NullString{String: "additive", Valid: true},
|
||
})
|
||
createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1)
|
||
|
||
// Second run rebuilds over the rows the first run wrote
|
||
if err := entitlements.MaterializePoolEntitlements(ctx, q, to.pool.PoolID); err != nil {
|
||
t.Fatalf("materialize over existing rows: %v", err)
|
||
}
|
||
|
||
for key, wantLimit := range map[string]int64{"fedwiki_sites": 5, secondKey: 3} {
|
||
ent, err := q.GetNumericEntitlementByPoolAndResource(ctx, entitlements.GetNumericEntitlementByPoolAndResourceParams{
|
||
PoolID: to.pool.PoolID,
|
||
ResourceKey: key,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("get entitlement for %s: %v", key, err)
|
||
}
|
||
if ent.ResourceLimit != wantLimit {
|
||
t.Errorf("expected limit=%d for %s, got %d", wantLimit, key, ent.ResourceLimit)
|
||
}
|
||
contribs, err := q.ListContributionsByEntitlementID(ctx, ent.EntitlementID)
|
||
if err != nil {
|
||
t.Fatalf("list contributions for %s: %v", key, err)
|
||
}
|
||
if len(contribs) != 1 {
|
||
t.Errorf("expected exactly one contribution row for %s, got %d", key, len(contribs))
|
||
}
|
||
}
|
||
}
|
||
|
||
// snapshotPoolRows renders every row a pool owns in the four materialized
|
||
// tables and in the two ledger tables keyed by pool as text, so a dry run or
|
||
// a rolled-back commit can be shown to leave them byte-identical.
|
||
func snapshotPoolRows(t *testing.T, ctx context.Context, tx *sql.Tx, poolID string) string {
|
||
t.Helper()
|
||
queries := []string{
|
||
`SELECT coalesce(string_agg(n::text, '|' ORDER BY n.resource_key), '')
|
||
FROM core.numeric_entitlements n WHERE n.pool_id = $1`,
|
||
`SELECT coalesce(string_agg(c::text, '|' ORDER BY c.entitlement_id, c.provision_id), '')
|
||
FROM core.numeric_entitlement_contributions c
|
||
JOIN core.numeric_entitlements n ON n.entitlement_id = c.entitlement_id
|
||
WHERE n.pool_id = $1`,
|
||
`SELECT coalesce(string_agg(u::text, '|' ORDER BY u.resource_key), '')
|
||
FROM core.numeric_entitlement_usage u WHERE u.pool_id = $1`,
|
||
`SELECT coalesce(string_agg(b::text, '|' ORDER BY b.resource_key), '')
|
||
FROM core.boolean_entitlements b WHERE b.pool_id = $1`,
|
||
`SELECT coalesce(string_agg(o::text, '|' ORDER BY o.change_id), '')
|
||
FROM core.entitlement_set_change_obligations o WHERE o.pool_id = $1`,
|
||
`SELECT coalesce(string_agg(e::text, '|' ORDER BY e.effect_id), '')
|
||
FROM core.entitlement_set_change_effects e WHERE e.pool_id = $1`,
|
||
}
|
||
var out string
|
||
for _, query := range queries {
|
||
var rows string
|
||
if err := tx.QueryRowContext(ctx, query, poolID).Scan(&rows); err != nil {
|
||
t.Fatalf("snapshot rows: %v", err)
|
||
}
|
||
out += rows + "\n"
|
||
}
|
||
return out
|
||
}
|
||
|
||
func stateFor(t *testing.T, states []entitlements.KeyState, key string) entitlements.KeyState {
|
||
t.Helper()
|
||
for _, st := range states {
|
||
if st.ResourceKey == key {
|
||
return st
|
||
}
|
||
}
|
||
t.Fatalf("no would-be state for %s in %+v", key, states)
|
||
return entitlements.KeyState{}
|
||
}
|
||
|
||
func firstActiveRule(t *testing.T, ctx context.Context, q *entitlements.Queries, setID string) entitlements.EntitlementSetRule {
|
||
t.Helper()
|
||
rules, err := q.GetActiveRulesBySetID(ctx, setID)
|
||
if err != nil || len(rules) == 0 {
|
||
t.Fatalf("active rules for %s: %v (%d)", setID, err, len(rules))
|
||
}
|
||
return rules[0]
|
||
}
|
||
|
||
// Scenario: the compute step with no overlay returns exactly what the apply
|
||
// step then writes, for a fed numeric key, a numeric key falling to 0 and a
|
||
// boolean key.
|
||
func TestDryRunMatchesApply(t *testing.T) {
|
||
database := testDB(t)
|
||
ctx := context.Background()
|
||
tx, err := entitlements.BeginRuleChange(ctx, database)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
q := entitlements.New(tx)
|
||
to := setupTestOrg(t, ctx, tx)
|
||
secondKey := "alpha.num-" + uuid.New().String()[:8]
|
||
if _, err := tx.ExecContext(ctx,
|
||
`INSERT INTO core.resource_keys (resource_key, display_name, unit) VALUES ($1, $2, 'items')`,
|
||
secondKey, "Test Numeric "+secondKey,
|
||
); err != nil {
|
||
t.Fatalf("create resource key: %v", err)
|
||
}
|
||
boolKey := "test.bool-" + uuid.New().String()[:8]
|
||
createBoolResourceKey(t, ctx, tx, boolKey)
|
||
|
||
numeric := createTestProduct(t, ctx, tx, "DryRunNumeric", 5, false)
|
||
second := createTestProduct(t, ctx, tx, "DryRunSecond", 3, false)
|
||
boolean := createBoolTestProduct(t, ctx, tx, "DryRunBool", boolKey)
|
||
// Point the second product's set at the second key: a rule's resource key
|
||
// is not editable, so the fedwiki_sites rule is deactivated and a rule for
|
||
// the second key added in its place.
|
||
deactivateSetRules(t, ctx, q, second.setID)
|
||
addSetRule(t, ctx, q, second.setID, entitlements.RuleFields{
|
||
RuleType: "limit",
|
||
ResourceKey: sql.NullString{String: secondKey, Valid: true},
|
||
ResourceValue: sql.NullInt64{Int64: 3, Valid: true},
|
||
ResourcePerUnit: sql.NullBool{Bool: false, Valid: true},
|
||
StackingPolicy: sql.NullString{String: "additive", Valid: true},
|
||
})
|
||
createGrantAndProvision(t, ctx, tx, q, to, numeric.productID, 1)
|
||
createGrantAndProvision(t, ctx, tx, q, to, second.productID, 1)
|
||
createGrantAndProvision(t, ctx, tx, q, to, boolean.productID, 1)
|
||
|
||
// The second key loses its only rule, so the fold no longer feeds it
|
||
deactivateSetRules(t, ctx, q, second.setID)
|
||
|
||
states, err := entitlements.DryRunPoolEntitlements(ctx, q, to.pool.PoolID, nil)
|
||
if err != nil {
|
||
t.Fatalf("dry run: %v", err)
|
||
}
|
||
if err := entitlements.MaterializePoolEntitlements(ctx, q, to.pool.PoolID); err != nil {
|
||
t.Fatalf("materialize: %v", err)
|
||
}
|
||
|
||
if len(states) != 3 {
|
||
t.Fatalf("expected 3 would-be states, got %d: %+v", len(states), states)
|
||
}
|
||
for _, st := range states {
|
||
if st.Boolean {
|
||
be, err := getBooleanEntitlement(t, ctx, q, to.pool.PoolID, st.ResourceKey)
|
||
if err != nil {
|
||
t.Fatalf("boolean row for %s: %v", st.ResourceKey, err)
|
||
}
|
||
if be.Granted != st.IsEnabled {
|
||
t.Errorf("%s: dry run said enabled=%v, apply wrote granted=%v", st.ResourceKey, st.IsEnabled, be.Granted)
|
||
}
|
||
continue
|
||
}
|
||
ent, err := q.GetNumericEntitlementByPoolAndResource(ctx, entitlements.GetNumericEntitlementByPoolAndResourceParams{
|
||
PoolID: to.pool.PoolID,
|
||
ResourceKey: st.ResourceKey,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("numeric row for %s: %v", st.ResourceKey, err)
|
||
}
|
||
if ent.ResourceLimit != st.ResourceLimit {
|
||
t.Errorf("%s: dry run said limit=%d, apply wrote %d", st.ResourceKey, st.ResourceLimit, ent.ResourceLimit)
|
||
}
|
||
}
|
||
if st := stateFor(t, states, "fedwiki_sites"); st.ResourceLimit != 5 {
|
||
t.Errorf("expected fedwiki_sites would-be 5, got %d", st.ResourceLimit)
|
||
}
|
||
if st := stateFor(t, states, secondKey); st.ResourceLimit != 0 {
|
||
t.Errorf("expected %s would-be 0 after its rule went, got %d", secondKey, st.ResourceLimit)
|
||
}
|
||
if st := stateFor(t, states, boolKey); !st.IsEnabled {
|
||
t.Errorf("expected %s would-be enabled", boolKey)
|
||
}
|
||
}
|
||
|
||
// Scenario: overlays that raise, lower, add and deactivate a rule each
|
||
// return the would-be state, and none of them writes a byte.
|
||
func TestDryRunOverlaysWriteNothing(t *testing.T) {
|
||
database := testDB(t)
|
||
ctx := context.Background()
|
||
tx, err := entitlements.BeginRuleChange(ctx, database)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
q := entitlements.New(tx)
|
||
to := setupTestOrg(t, ctx, tx)
|
||
product := createTestProduct(t, ctx, tx, "OverlayProduct", 5, false)
|
||
createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1)
|
||
rule := firstActiveRule(t, ctx, q, product.setID)
|
||
before := snapshotPoolRows(t, ctx, tx, to.pool.PoolID)
|
||
|
||
limitOverlay := func(value int64) entitlements.RuleOverlay {
|
||
id := rule.RuleID
|
||
return entitlements.RuleOverlay{
|
||
RuleID: &id,
|
||
SetID: product.setID,
|
||
RuleType: "limit",
|
||
ResourceKey: sql.NullString{String: "fedwiki_sites", Valid: true},
|
||
ResourceValue: sql.NullInt64{Int64: value, Valid: true},
|
||
ResourcePerUnit: sql.NullBool{Bool: false, Valid: true},
|
||
StackingPolicy: sql.NullString{String: "additive", Valid: true},
|
||
IsActive: true,
|
||
}
|
||
}
|
||
added := entitlements.RuleOverlay{
|
||
SetID: product.setID,
|
||
RuleType: "limit",
|
||
ResourceKey: sql.NullString{String: "alpha.added", Valid: true},
|
||
ResourceValue: sql.NullInt64{Int64: 3, Valid: true},
|
||
ResourcePerUnit: sql.NullBool{Bool: false, Valid: true},
|
||
StackingPolicy: sql.NullString{String: "additive", Valid: true},
|
||
IsActive: true,
|
||
}
|
||
deactivated := limitOverlay(5)
|
||
deactivated.IsActive = false
|
||
cases := []struct {
|
||
name string
|
||
overlays []entitlements.RuleOverlay
|
||
key string
|
||
want int64
|
||
}{
|
||
{"raise", []entitlements.RuleOverlay{limitOverlay(8)}, "fedwiki_sites", 8},
|
||
{"lower", []entitlements.RuleOverlay{limitOverlay(2)}, "fedwiki_sites", 2},
|
||
{"add", []entitlements.RuleOverlay{added}, "alpha.added", 3},
|
||
{"deactivate", []entitlements.RuleOverlay{deactivated}, "fedwiki_sites", 0},
|
||
// A batch: the edited rule and the added one folded together, one
|
||
// overlay per resource key.
|
||
{"batch", []entitlements.RuleOverlay{limitOverlay(2), added}, "alpha.added", 3},
|
||
}
|
||
for _, tc := range cases {
|
||
states, err := entitlements.DryRunPoolEntitlements(ctx, q, to.pool.PoolID, tc.overlays)
|
||
if err != nil {
|
||
t.Fatalf("%s: dry run: %v", tc.name, err)
|
||
}
|
||
if got := stateFor(t, states, tc.key).ResourceLimit; got != tc.want {
|
||
t.Errorf("%s: expected %s would-be %d, got %d", tc.name, tc.key, tc.want, got)
|
||
}
|
||
if tc.name == "add" {
|
||
if got := stateFor(t, states, "fedwiki_sites").ResourceLimit; got != 5 {
|
||
t.Errorf("add: expected fedwiki_sites unchanged at 5, got %d", got)
|
||
}
|
||
}
|
||
if tc.name == "batch" {
|
||
if got := stateFor(t, states, "fedwiki_sites").ResourceLimit; got != 2 {
|
||
t.Errorf("batch: expected fedwiki_sites at the edited 2, got %d", got)
|
||
}
|
||
}
|
||
}
|
||
|
||
if after := snapshotPoolRows(t, ctx, tx, to.pool.PoolID); after != before {
|
||
t.Errorf("dry runs changed rows:\nbefore:\n%s\nafter:\n%s", before, after)
|
||
}
|
||
if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 5 {
|
||
t.Errorf("expected stored limit still 5 after dry runs, got %d", limit)
|
||
}
|
||
}
|