Add default_plan_ladder_id with a forward data migration and update the runtime to resolve the ladder's rank-0 tier at use-time. Regenerate sqlc, update auto-provisioning, ReapplyDefaultsForPool, operator UI and tests; add GetTierByLadderRank and pool/provision query helpers. Add a CSP-safe confirm-action modal and wire operator actions to it. Close plan-sole-writer safety gaps and serialize IssueGrant with a FOR UPDATE pool lock to prevent ladder races.
405 lines
13 KiB
Go
405 lines
13 KiB
Go
package provisioning_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"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)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// TestAutoProvisionWithoutDefaultProduct verifies that provisioning
|
|
// succeeds and DefaultGrant is nil when no default product is configured.
|
|
func TestAutoProvisionWithoutDefaultProduct(t *testing.T) {
|
|
database := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
// Verify the personal org type has no default product
|
|
tx, err := database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
orgQ := organization.New(tx)
|
|
orgType, err := orgQ.GetOrgType(ctx, "personal")
|
|
if err != nil {
|
|
t.Fatalf("get org type: %v", err)
|
|
}
|
|
tx.Rollback()
|
|
|
|
if orgType.DefaultPlanLadderID.Valid {
|
|
t.Fatal("expected personal org type to have no default plan for this test")
|
|
}
|
|
|
|
claims := provisioning.OIDCClaims{
|
|
Subject: uuid.New().String(),
|
|
Email: "no-default-" + uuid.New().String()[:8] + "@example.com",
|
|
EmailVerified: true,
|
|
Name: "No Default User",
|
|
PreferredUsername: "no-default-" + uuid.New().String()[:8],
|
|
}
|
|
|
|
result, err := provisioning.AutoProvision(ctx, database, claims)
|
|
if err != nil {
|
|
t.Fatalf("auto-provision: %v", err)
|
|
}
|
|
|
|
// Core structures should be created
|
|
if result.User.UserID == "" {
|
|
t.Error("expected user to be created")
|
|
}
|
|
if result.Person.PersonID == "" {
|
|
t.Error("expected person to be created")
|
|
}
|
|
if result.Org.OrgID == "" {
|
|
t.Error("expected org to be created")
|
|
}
|
|
if result.Pool.PoolID == "" {
|
|
t.Error("expected pool to be created")
|
|
}
|
|
if result.BillingAccount.BillingAccountID == "" {
|
|
t.Error("expected billing account to be created")
|
|
}
|
|
|
|
// No default grant should be created
|
|
if result.DefaultGrant != nil {
|
|
t.Error("expected DefaultGrant to be nil when no default product is configured")
|
|
}
|
|
|
|
// Verify no entitlements were materialized on the pool
|
|
tx2, err := database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer tx2.Rollback()
|
|
|
|
entQ := entitlements.New(tx2)
|
|
_, err = entQ.GetNumericEntitlementByPoolAndResource(ctx, entitlements.GetNumericEntitlementByPoolAndResourceParams{
|
|
PoolID: result.Pool.PoolID,
|
|
ResourceKey: "sites",
|
|
})
|
|
if err != sql.ErrNoRows {
|
|
t.Errorf("expected no entitlements on pool, got err=%v", err)
|
|
}
|
|
}
|
|
|
|
// TestAutoProvisionWithDefaultProduct verifies that when a default product
|
|
// is configured on the org type, provisioning creates a system grant
|
|
// populating both product_id and entitlement_set_id, and materializes
|
|
// entitlements automatically.
|
|
func TestAutoProvisionWithDefaultProduct(t *testing.T) {
|
|
database := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
// Setup: create an entitlement set + product, configure product as the personal org type default
|
|
tx, err := database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
entQ := entitlements.New(tx)
|
|
billQ := billing.New(tx)
|
|
orgQ := organization.New(tx)
|
|
|
|
// Create an entitlement set with a sites limit rule
|
|
set, err := entQ.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
|
|
Name: "Public Tier Default",
|
|
IsActive: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create entitlement set: %v", err)
|
|
}
|
|
|
|
_, err = entQ.CreateEntitlementSetRule(ctx, entitlements.CreateEntitlementSetRuleParams{
|
|
SetID: set.SetID,
|
|
RuleType: "limit",
|
|
ResourceKey: sql.NullString{String: "sites", Valid: true},
|
|
ResourceValue: sql.NullInt64{Int64: 1, 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 product backed by that entitlement set. Plan products carry
|
|
// NULL product_type per Doc 31 Amendment #3 — kind is derived structurally
|
|
// via plan_ladder_tiers membership.
|
|
product, err := billQ.CreateProduct(ctx, billing.CreateProductParams{
|
|
Name: "Public Tier",
|
|
ProductType: sql.NullString{},
|
|
IsActive: true,
|
|
IsPublic: true,
|
|
EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(set.SetID), Valid: true},
|
|
LifecycleStatus: "published",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create product: %v", err)
|
|
}
|
|
|
|
// Place the product on a plan ladder at rank 0 so it can serve as the
|
|
// default plan (default-plan resolution requires a rank-0 ladder tier).
|
|
ladder, err := billQ.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{
|
|
LadderKey: "test-default-" + uuid.New().String()[:8],
|
|
Name: "Test Default Ladder",
|
|
Description: sql.NullString{String: "auto-provision default-plan test", Valid: true},
|
|
IsActive: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create plan ladder: %v", err)
|
|
}
|
|
if _, err := billQ.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{
|
|
PlanLadderID: ladder.PlanLadderID,
|
|
ProductID: product.ProductID,
|
|
Rank: 0,
|
|
}); err != nil {
|
|
t.Fatalf("create plan ladder tier: %v", err)
|
|
}
|
|
|
|
// Configure personal org type to use this ladder as its default plan
|
|
_, err = orgQ.UpdateOrgTypeDefaultPlanLadder(ctx, organization.UpdateOrgTypeDefaultPlanLadderParams{
|
|
OrgType: "personal",
|
|
DefaultPlanLadderID: uuid.NullUUID{UUID: uuid.MustParse(ladder.PlanLadderID), Valid: true},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("update org type default plan: %v", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
t.Fatalf("commit setup: %v", err)
|
|
}
|
|
|
|
// Reset the default after this test so other tests aren't affected
|
|
t.Cleanup(func() {
|
|
cleanTx, err := database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
oq := organization.New(cleanTx)
|
|
oq.UpdateOrgTypeDefaultPlanLadder(ctx, organization.UpdateOrgTypeDefaultPlanLadderParams{
|
|
OrgType: "personal",
|
|
DefaultPlanLadderID: uuid.NullUUID{Valid: false},
|
|
})
|
|
cleanTx.Commit()
|
|
})
|
|
|
|
// Now provision a new user
|
|
claims := provisioning.OIDCClaims{
|
|
Subject: uuid.New().String(),
|
|
Email: "with-default-" + uuid.New().String()[:8] + "@example.com",
|
|
EmailVerified: true,
|
|
Name: "Default User",
|
|
PreferredUsername: "with-default-" + uuid.New().String()[:8],
|
|
}
|
|
|
|
result, err := provisioning.AutoProvision(ctx, database, claims)
|
|
if err != nil {
|
|
t.Fatalf("auto-provision: %v", err)
|
|
}
|
|
|
|
// Core structures should be created
|
|
if result.Org.OrgID == "" {
|
|
t.Error("expected org to be created")
|
|
}
|
|
|
|
// Default grant SHOULD be populated
|
|
if result.DefaultGrant == nil {
|
|
t.Fatal("expected DefaultGrant to be populated when default product is configured")
|
|
}
|
|
|
|
// Verify grant properties — both product_id and entitlement_set_id must be set
|
|
grant := result.DefaultGrant.Grant
|
|
if grant.GrantReason != "default" {
|
|
t.Errorf("expected grant_reason='default', got %q", grant.GrantReason)
|
|
}
|
|
if grant.GrantedByPersonID.Valid {
|
|
t.Error("expected granted_by_person_id to be NULL for system grant")
|
|
}
|
|
if !grant.ProductID.Valid || grant.ProductID.UUID.String() != product.ProductID {
|
|
t.Errorf("expected product_id=%s, got %v", product.ProductID, grant.ProductID)
|
|
}
|
|
if !grant.EntitlementSetID.Valid || grant.EntitlementSetID.UUID.String() != set.SetID {
|
|
t.Errorf("expected entitlement_set_id=%s (resolved from product), got %v", set.SetID, grant.EntitlementSetID)
|
|
}
|
|
|
|
// Verify entitlements were materialized on the pool
|
|
tx2, err := database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer tx2.Rollback()
|
|
|
|
entQ2 := entitlements.New(tx2)
|
|
ent, err := entQ2.GetNumericEntitlementByPoolAndResource(ctx, entitlements.GetNumericEntitlementByPoolAndResourceParams{
|
|
PoolID: result.Pool.PoolID,
|
|
ResourceKey: "sites",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected entitlement to exist: %v", err)
|
|
}
|
|
|
|
if ent.ResourceLimit != 1 {
|
|
t.Errorf("expected resource_limit=1 (from Public Tier Default set), got %d", ent.ResourceLimit)
|
|
}
|
|
}
|
|
|
|
// TestAutoProvisionTransactionIntegrity verifies that provisioning with a
|
|
// product backed by an empty entitlement set still commits the user/org/grant
|
|
// (materialization simply produces no entitlements) — tests the transactional
|
|
// integrity of the flow.
|
|
func TestAutoProvisionTransactionIntegrity(t *testing.T) {
|
|
database := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
// Setup: create an empty entitlement set + product, configure product as default
|
|
tx, err := database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
entQ := entitlements.New(tx)
|
|
billQ := billing.New(tx)
|
|
orgQ := organization.New(tx)
|
|
|
|
emptySet, err := entQ.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
|
|
Name: "Empty Set (no rules)",
|
|
IsActive: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create empty entitlement set: %v", err)
|
|
}
|
|
|
|
emptyProduct, err := billQ.CreateProduct(ctx, billing.CreateProductParams{
|
|
Name: "Empty Product",
|
|
ProductType: sql.NullString{},
|
|
IsActive: true,
|
|
IsPublic: false,
|
|
EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(emptySet.SetID), Valid: true},
|
|
LifecycleStatus: "published",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create empty product: %v", err)
|
|
}
|
|
|
|
// Place the empty product on a ladder at rank 0 so it can serve as the
|
|
// default plan (default-plan resolution requires a rank-0 ladder tier).
|
|
emptyLadder, err := billQ.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{
|
|
LadderKey: "test-empty-default-" + uuid.New().String()[:8],
|
|
Name: "Test Empty-Set Default Ladder",
|
|
Description: sql.NullString{String: "empty-set default-plan test", Valid: true},
|
|
IsActive: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create empty-set plan ladder: %v", err)
|
|
}
|
|
if _, err := billQ.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{
|
|
PlanLadderID: emptyLadder.PlanLadderID,
|
|
ProductID: emptyProduct.ProductID,
|
|
Rank: 0,
|
|
}); err != nil {
|
|
t.Fatalf("create empty-set plan ladder tier: %v", err)
|
|
}
|
|
|
|
_, err = orgQ.UpdateOrgTypeDefaultPlanLadder(ctx, organization.UpdateOrgTypeDefaultPlanLadderParams{
|
|
OrgType: "personal",
|
|
DefaultPlanLadderID: uuid.NullUUID{UUID: uuid.MustParse(emptyLadder.PlanLadderID), Valid: true},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("update org type default plan: %v", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
t.Fatalf("commit setup: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
cleanTx, err := database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
oq := organization.New(cleanTx)
|
|
oq.UpdateOrgTypeDefaultPlanLadder(ctx, organization.UpdateOrgTypeDefaultPlanLadderParams{
|
|
OrgType: "personal",
|
|
DefaultPlanLadderID: uuid.NullUUID{Valid: false},
|
|
})
|
|
cleanTx.Commit()
|
|
})
|
|
|
|
// Provision with a product backed by an empty set — should still succeed
|
|
claims := provisioning.OIDCClaims{
|
|
Subject: uuid.New().String(),
|
|
Email: "empty-set-" + uuid.New().String()[:8] + "@example.com",
|
|
EmailVerified: true,
|
|
Name: "Empty Set User",
|
|
PreferredUsername: "empty-set-" + uuid.New().String()[:8],
|
|
}
|
|
|
|
result, err := provisioning.AutoProvision(ctx, database, claims)
|
|
if err != nil {
|
|
t.Fatalf("auto-provision with empty set: %v", err)
|
|
}
|
|
|
|
// Grant should exist even with an empty set
|
|
if result.DefaultGrant == nil {
|
|
t.Fatal("expected DefaultGrant to be populated even with an empty entitlement set")
|
|
}
|
|
|
|
// Verify that the user, org, and all structures were committed
|
|
tx2, err := database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer tx2.Rollback()
|
|
|
|
idQ := identity.New(tx2)
|
|
_, err = idQ.GetUserByOIDCSubject(ctx, claims.Subject)
|
|
if err != nil {
|
|
t.Errorf("expected user to be committed: %v", err)
|
|
}
|
|
|
|
// Verify no entitlements since the set has no rules
|
|
entQ2 := entitlements.New(tx2)
|
|
_, err = entQ2.GetNumericEntitlementByPoolAndResource(ctx, entitlements.GetNumericEntitlementByPoolAndResourceParams{
|
|
PoolID: result.Pool.PoolID,
|
|
ResourceKey: "sites",
|
|
})
|
|
if err != sql.ErrNoRows {
|
|
t.Errorf("expected no entitlements from empty set, got err=%v", err)
|
|
}
|
|
}
|