Files
member-console/internal/db/schema_hardening_test.go
T
cgalo5758 dd3962990b Adopt entity keys and add invoice numbers
Replace the entity slugs on organizations, workspaces, resource pools,
and
plan ladders with nullable `key` columns and add keys to products,
prices,
and entitlement sets. Rename `providers.slug` to `provider` and add
partial
unique indexes for system and org role names.

Assign invoice numbers per billing account from a gapless transactional
counter; Stripe's number moves to the invoice mapping as an external
reference.

Seeds, fixtures, and the operator lookup address rows by key, and the
returning-login resync no longer blanks a display name when the IdP
sends
no `name` claim.
2026-08-29 20:12:04 -05:00

643 lines
26 KiB
Go

package db_test
// DB-backed tests for migration 00010_schema_hardening.sql (schema-hardening
// change, tasks.md 1.2). Harness mirrors partitions_test.go: skip without
// TEST_DATABASE_URL, migrate via db.RunMigrations(database, db.BaseSources()).
//
// Dedup-preflight test note: db.RunMigrations always applies every migration,
// including 00010, before a test body runs. That means the unique index
// uq_pool_assignments_one_primary_per_workspace already exists by the time
// TestPoolAssignments_DedupPreflightDemotesDuplicate starts, so a duplicate
// primary can't be inserted "before 00010 runs" inside this harness. The
// cleanest honest reproduction is: drop the index, insert two primary
// assignments for the same workspace with distinct created_at values, replay
// the exact WITH/UPDATE statement 00010's Up section runs (copied verbatim
// below as dedupPreflightSQL), and assert it keeps the earliest and demotes
// the rest. The index is then recreated and proven to reject a fresh
// duplicate, closing the loop between "the pre-flight cleans existing data"
// and "the index keeps it clean afterward". This duplicates the migration's
// SQL text intentionally (there is no seam to invoke it in place); if 00010's
// dedup block changes, dedupPreflightSQL must change with it.
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5/pgconn"
_ "github.com/jackc/pgx/v5/stdlib"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
)
// dedupPreflightSQL mirrors 00010_schema_hardening.sql's pre-flight block
// verbatim (see the file-level comment above).
const dedupPreflightSQL = `
WITH ranked AS (
SELECT assignment_id,
ROW_NUMBER() OVER (
PARTITION BY workspace_id
ORDER BY created_at ASC, assignment_id ASC
) AS rn
FROM core.pool_assignments
WHERE is_primary
)
UPDATE core.pool_assignments
SET is_primary = FALSE
WHERE assignment_id IN (SELECT assignment_id FROM ranked WHERE rn > 1);
`
// newSchemaHardeningTestDB opens the test DB and runs core's migrations —
// resource_pools, pool_assignments, providers, and subscriptions all live in
// the core schema, so db.BaseSources() is sufficient (mirrors
// partitions_test.go's newPartitionsTestDB).
func newSchemaHardeningTestDB(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("open db: %v", err)
}
t.Cleanup(func() { database.Close() })
if err := db.RunMigrations(database, db.BaseSources()); err != nil {
t.Fatalf("migrations: %v", err)
}
return database
}
// schemaHardeningSeq guarantees fixture labels are unique within and across
// runs against a not-reset-between-bare-go-test-reruns database (same
// concern partitions_test.go's synthetic-month comment documents), for the
// handful of columns here that carry a database-wide UNIQUE constraint
// (users.oidc_subject, providers.provider, and the name indexes 00012 and
// 00014 add).
var schemaHardeningSeq atomic.Int64
func uniqueLabel(prefix string) string {
return fmt.Sprintf("%s%d%d", prefix, time.Now().UnixNano(), schemaHardeningSeq.Add(1))
}
// assertPgError requires err to be a *pgconn.PgError with the given SQLSTATE
// code, and — when wantConstraint is non-empty — the given constraint name.
func assertPgError(t *testing.T, err error, wantCode, wantConstraint string) {
t.Helper()
if err == nil {
t.Fatalf("expected error (SQLSTATE %s, constraint %q), got nil", wantCode, wantConstraint)
}
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
t.Fatalf("expected a *pgconn.PgError, got %T: %v", err, err)
}
if pgErr.Code != wantCode {
t.Errorf("SQLSTATE = %s, want %s (err: %v)", pgErr.Code, wantCode, err)
}
if wantConstraint != "" && pgErr.ConstraintName != wantConstraint {
t.Errorf("constraint = %q, want %q (err: %v)", pgErr.ConstraintName, wantConstraint, err)
}
}
// --- fixture helpers --------------------------------------------------
// mustInsertPerson creates a core.users + core.persons row and returns the
// person_id, the minimal identity fixture organizations.owner_person_id needs.
func mustInsertPerson(t *testing.T, ctx context.Context, database *sql.DB, label string) string {
t.Helper()
var userID string
if err := database.QueryRowContext(ctx,
`INSERT INTO core.users (oidc_subject) VALUES ($1) RETURNING user_id`,
uniqueLabel(label+"-oidc-")).Scan(&userID); err != nil {
t.Fatalf("insert user: %v", err)
}
var personID string
if err := database.QueryRowContext(ctx,
`INSERT INTO core.persons (user_id, display_name, primary_email) VALUES ($1, $2, $3) RETURNING person_id`,
userID, label, label+"@example.test").Scan(&personID); err != nil {
t.Fatalf("insert person: %v", err)
}
return personID
}
// mustInsertOrg creates a core.organizations row (org_type 'personal', seeded
// by 00003) and returns its org_id.
func mustInsertOrg(t *testing.T, ctx context.Context, database *sql.DB, label, personID string) string {
t.Helper()
var orgID string
if err := database.QueryRowContext(ctx,
`INSERT INTO core.organizations (name, org_type, owner_person_id)
VALUES ($1, 'personal', $2) RETURNING org_id`,
label, personID).Scan(&orgID); err != nil {
t.Fatalf("insert org: %v", err)
}
return orgID
}
// mustInsertWorkspace creates a core.workspaces row and returns its workspace_id.
func mustInsertWorkspace(t *testing.T, ctx context.Context, database *sql.DB, label, orgID string) string {
t.Helper()
var workspaceID string
if err := database.QueryRowContext(ctx,
`INSERT INTO core.workspaces (org_id, name) VALUES ($1, $2) RETURNING workspace_id`,
orgID, uniqueLabel(label+"-")).Scan(&workspaceID); err != nil {
t.Fatalf("insert workspace: %v", err)
}
return workspaceID
}
// mustInsertAccount creates a core.accounts (billing account) row and returns
// its billing_account_id.
func mustInsertAccount(t *testing.T, ctx context.Context, database *sql.DB, label, orgID string) string {
t.Helper()
var accountID string
if err := database.QueryRowContext(ctx,
`INSERT INTO core.accounts (org_id, name) VALUES ($1, $2) RETURNING billing_account_id`,
orgID, label).Scan(&accountID); err != nil {
t.Fatalf("insert account: %v", err)
}
return accountID
}
// insertPool attempts a core.resource_pools insert with the given pool_type,
// returning the new pool_id or the error the database raised.
func insertPool(ctx context.Context, database *sql.DB, orgID, label, poolType string) (string, error) {
var poolID string
err := database.QueryRowContext(ctx,
`INSERT INTO core.resource_pools (org_id, name, pool_type) VALUES ($1, $2, $3) RETURNING pool_id`,
orgID, label, poolType).Scan(&poolID)
return poolID, err
}
func mustInsertPool(t *testing.T, ctx context.Context, database *sql.DB, label, orgID, poolType string) string {
t.Helper()
poolID, err := insertPool(ctx, database, orgID, uniqueLabel(label+"-"), poolType)
if err != nil {
t.Fatalf("insert pool (type %s): %v", poolType, err)
}
return poolID
}
// --- 1.2: resource_pools -----------------------------------------------
func TestResourcePools_SecondDefaultPoolRejected(t *testing.T) {
database := newSchemaHardeningTestDB(t)
ctx := context.Background()
personID := mustInsertPerson(t, ctx, database, "rp-default-person")
orgID := mustInsertOrg(t, ctx, database, "rp-default-org", personID)
// First default pool for the org succeeds.
mustInsertPool(t, ctx, database, "rp-default-pool-1", orgID, "default")
// A second default pool for the SAME org, under a different name, is
// rejected by the partial unique index — proving the index is what
// closes the gap.
_, err := insertPool(ctx, database, orgID, uniqueLabel("rp-default-pool-2-"), "default")
assertPgError(t, err, "23505", "uq_resource_pools_one_default_per_org")
// A non-default pool for the same org is unaffected.
if _, err := insertPool(ctx, database, orgID, uniqueLabel("rp-shared-pool-"), "shared"); err != nil {
t.Errorf("non-default pool for an org that already has a default: %v", err)
}
}
func TestResourcePools_PoolTypeCheckRejectsUnknownValue(t *testing.T) {
database := newSchemaHardeningTestDB(t)
ctx := context.Background()
personID := mustInsertPerson(t, ctx, database, "rp-type-person")
orgID := mustInsertOrg(t, ctx, database, "rp-type-org", personID)
// Every documented value (design/data-model.md's resource_pools section:
// default | shared | dedicated) inserts cleanly, even though only
// 'default' is written by any code path today.
for _, poolType := range []string{"default", "shared", "dedicated"} {
if _, err := insertPool(ctx, database, orgID, uniqueLabel("rp-type-"+poolType+"-"), poolType); err != nil {
t.Errorf("pool_type %q rejected, want accepted: %v", poolType, err)
}
}
// An unknown value is rejected by the CHECK, not silently stored.
_, err := insertPool(ctx, database, orgID, uniqueLabel("rp-type-bogus-"), "bogus")
assertPgError(t, err, "23514", "chk_resource_pools_pool_type_valid")
}
// --- 1.2: pool_assignments ----------------------------------------------
func TestPoolAssignments_SecondPrimaryRejected(t *testing.T) {
database := newSchemaHardeningTestDB(t)
ctx := context.Background()
personID := mustInsertPerson(t, ctx, database, "pa-primary-person")
orgID := mustInsertOrg(t, ctx, database, "pa-primary-org", personID)
workspaceID := mustInsertWorkspace(t, ctx, database, "pa-primary-ws", orgID)
poolA := mustInsertPool(t, ctx, database, "pa-primary-pool-a", orgID, "default")
poolB := mustInsertPool(t, ctx, database, "pa-primary-pool-b", orgID, "shared")
if _, err := database.ExecContext(ctx,
`INSERT INTO core.pool_assignments (pool_id, workspace_id, is_primary) VALUES ($1, $2, TRUE)`,
poolA, workspaceID); err != nil {
t.Fatalf("insert first primary assignment: %v", err)
}
// A second primary assignment for the same workspace, in a different
// pool, is rejected — this is exactly the "one acquisition debits two
// pools' counters" gap the index closes.
_, err := database.ExecContext(ctx,
`INSERT INTO core.pool_assignments (pool_id, workspace_id, is_primary) VALUES ($1, $2, TRUE)`,
poolB, workspaceID)
assertPgError(t, err, "23505", "uq_pool_assignments_one_primary_per_workspace")
// A non-primary assignment to the same workspace, in the other pool, is
// unaffected — the constraint is partial (WHERE is_primary), not a blanket
// one-pool-per-workspace rule.
if _, err := database.ExecContext(ctx,
`INSERT INTO core.pool_assignments (pool_id, workspace_id, is_primary) VALUES ($1, $2, FALSE)`,
poolB, workspaceID); err != nil {
t.Errorf("non-primary second assignment rejected, want accepted: %v", err)
}
}
func TestPoolAssignments_DedupPreflightDemotesDuplicate(t *testing.T) {
database := newSchemaHardeningTestDB(t)
ctx := context.Background()
personID := mustInsertPerson(t, ctx, database, "pa-dedup-person")
orgID := mustInsertOrg(t, ctx, database, "pa-dedup-org", personID)
workspaceID := mustInsertWorkspace(t, ctx, database, "pa-dedup-ws", orgID)
poolEarlier := mustInsertPool(t, ctx, database, "pa-dedup-pool-earlier", orgID, "default")
poolLater := mustInsertPool(t, ctx, database, "pa-dedup-pool-later", orgID, "shared")
// Drop the index 00010 creates so an artificial duplicate can be inserted
// directly — reproducing the pre-launch shape the pre-flight exists to
// clean up. Recreate it in cleanup even if the test body already does, so
// a failure partway through doesn't leave the schema altered for other
// tests sharing this database.
if _, err := database.ExecContext(ctx, `DROP INDEX core.uq_pool_assignments_one_primary_per_workspace`); err != nil {
t.Fatalf("drop index for reproduction: %v", err)
}
t.Cleanup(func() {
_, _ = database.ExecContext(context.Background(),
`CREATE UNIQUE INDEX IF NOT EXISTS uq_pool_assignments_one_primary_per_workspace
ON core.pool_assignments(workspace_id) WHERE is_primary`)
})
var earlierID, laterID string
if err := database.QueryRowContext(ctx, `
INSERT INTO core.pool_assignments (pool_id, workspace_id, is_primary, created_at)
VALUES ($1, $2, TRUE, NOW() - INTERVAL '1 hour') RETURNING assignment_id`,
poolEarlier, workspaceID).Scan(&earlierID); err != nil {
t.Fatalf("insert earlier duplicate primary: %v", err)
}
if err := database.QueryRowContext(ctx, `
INSERT INTO core.pool_assignments (pool_id, workspace_id, is_primary, created_at)
VALUES ($1, $2, TRUE, NOW()) RETURNING assignment_id`,
poolLater, workspaceID).Scan(&laterID); err != nil {
t.Fatalf("insert later duplicate primary: %v", err)
}
// Sanity: both really are primary right now (the reproduction worked).
for _, id := range []string{earlierID, laterID} {
var isPrimary bool
if err := database.QueryRowContext(ctx,
`SELECT is_primary FROM core.pool_assignments WHERE assignment_id = $1`, id).Scan(&isPrimary); err != nil {
t.Fatalf("scan pre-dedup is_primary for %s: %v", id, err)
}
if !isPrimary {
t.Fatalf("assignment %s not primary before dedup ran; reproduction failed", id)
}
}
// Run 00010's pre-flight statement (see dedupPreflightSQL doc comment).
if _, err := database.ExecContext(ctx, dedupPreflightSQL); err != nil {
t.Fatalf("dedup pre-flight: %v", err)
}
var earlierPrimary, laterPrimary bool
if err := database.QueryRowContext(ctx,
`SELECT is_primary FROM core.pool_assignments WHERE assignment_id = $1`, earlierID).Scan(&earlierPrimary); err != nil {
t.Fatalf("scan earlier post-dedup: %v", err)
}
if err := database.QueryRowContext(ctx,
`SELECT is_primary FROM core.pool_assignments WHERE assignment_id = $1`, laterID).Scan(&laterPrimary); err != nil {
t.Fatalf("scan later post-dedup: %v", err)
}
if !earlierPrimary {
t.Errorf("earliest duplicate (created_at NOW()-1h) was demoted, want it kept primary")
}
if laterPrimary {
t.Errorf("later duplicate (created_at NOW()) still primary, want it demoted")
}
// Recreate the index and prove it now guards the cleaned-up state: a
// fresh attempt to re-promote the demoted row to primary is rejected.
if _, err := database.ExecContext(ctx,
`CREATE UNIQUE INDEX uq_pool_assignments_one_primary_per_workspace
ON core.pool_assignments(workspace_id) WHERE is_primary`); err != nil {
t.Fatalf("recreate index: %v", err)
}
_, err := database.ExecContext(ctx,
`UPDATE core.pool_assignments SET is_primary = TRUE WHERE assignment_id = $1`, laterID)
assertPgError(t, err, "23505", "uq_pool_assignments_one_primary_per_workspace")
}
// --- 1.2: providers -------------------------------------------------------
func TestProviders_StatusCheckAndLifecycleTimestamps(t *testing.T) {
database := newSchemaHardeningTestDB(t)
ctx := context.Background()
insertProvider := func(provider, status string, suspendedAt, retiredAt any) error {
_, err := database.ExecContext(ctx,
`INSERT INTO core.providers (provider, provider_kind, display_name, status, suspended_at, retired_at)
VALUES ($1, 'payment', $1, $2, $3, $4)`,
provider, status, suspendedAt, retiredAt)
return err
}
// All three documented statuses insert cleanly, and the lifecycle
// timestamp columns exist and can carry a value (Scenario: "Lifecycle
// timestamps are available").
activeKey := "schprovactive" + fmt.Sprintf("%d", time.Now().UnixNano())
if err := insertProvider(activeKey, "active", nil, nil); err != nil {
t.Errorf("status active rejected: %v", err)
}
suspendedKey := "schprovsuspended" + fmt.Sprintf("%d", time.Now().UnixNano()+1)
if err := insertProvider(suspendedKey, "suspended", time.Now(), nil); err != nil {
t.Errorf("status suspended (with suspended_at) rejected: %v", err)
}
retiredKey := "schprovretired" + fmt.Sprintf("%d", time.Now().UnixNano()+2)
if err := insertProvider(retiredKey, "retired", nil, time.Now()); err != nil {
t.Errorf("status retired (with retired_at) rejected: %v", err)
}
var suspendedAt sql.NullTime
if err := database.QueryRowContext(ctx,
`SELECT suspended_at FROM core.providers WHERE provider = $1`, suspendedKey).Scan(&suspendedAt); err != nil {
t.Fatalf("scan suspended_at: %v", err)
}
if !suspendedAt.Valid {
t.Errorf("suspended_at not stored for suspended provider")
}
// An unknown status is rejected by the CHECK.
bogusKey := "schprovbogus" + fmt.Sprintf("%d", time.Now().UnixNano()+3)
err := insertProvider(bogusKey, "bogus", nil, nil)
assertPgError(t, err, "23514", "chk_providers_status_valid")
}
// --- 1.2: subscriptions ----------------------------------------------------
func TestSubscriptions_StatusCheckRejectsUnknownButAllStripeValuesInsert(t *testing.T) {
database := newSchemaHardeningTestDB(t)
ctx := context.Background()
personID := mustInsertPerson(t, ctx, database, "sub-status-person")
orgID := mustInsertOrg(t, ctx, database, "sub-status-org", personID)
accountID := mustInsertAccount(t, ctx, database, "sub-status-account", orgID)
insertSubscription := func(status string) error {
_, err := database.ExecContext(ctx,
`INSERT INTO core.subscriptions (billing_account_id, status) VALUES ($1, $2)`,
accountID, status)
return err
}
// Every value in Stripe's closed subscription-status vocabulary,
// including incomplete_expired (the value the designed vocabulary
// currently omits but the reconciler genuinely receives), inserts
// cleanly.
stripeStatuses := []string{
"incomplete", "incomplete_expired", "trialing", "active",
"past_due", "canceled", "unpaid", "paused",
}
for _, status := range stripeStatuses {
if err := insertSubscription(status); err != nil {
t.Errorf("status %q rejected, want accepted: %v", status, err)
}
}
// A status outside the vocabulary (e.g. a manual-SQL typo) is rejected.
err := insertSubscription("bogus_status")
assertPgError(t, err, "23514", "chk_subscriptions_status_valid")
}
// --- 00012: the three name guards ----------------------------------------
//
// Migration 00012 renames the organization, workspace, and pool identifier
// columns to `key` and adds three uniqueness guards over names. These tests
// prove each index at the database, which is where the invariant now lives.
// The System tenant is a singleton: uq_organizations_one_system is a partial
// unique index on org_type where org_type = 'system', so a second system
// organization cannot be inserted no matter what it is named.
func TestOrganizations_SecondSystemOrganizationRejected(t *testing.T) {
database := newSchemaHardeningTestDB(t)
ctx := context.Background()
personID := mustInsertPerson(t, ctx, database, "sys-org-person")
// Everything below runs in one transaction that is rolled back: a
// leaked system organization owned by this fixture person would be
// adopted by systemtenant.Ensure in later packages (the index allows
// only one) and change which person the People directory excludes.
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback() //nolint:errcheck
// The 'system' org type is boot-ensured (systemtenant.Ensure), never
// migration-seeded, so a fresh test database lacks it; the FK on
// organizations.org_type needs the row before any system org can exist.
if _, err := tx.ExecContext(ctx,
`INSERT INTO core.org_types (org_type, display_name, description, is_active, is_reserved)
VALUES ('system', 'System', 'Reserved platform tenant', TRUE, TRUE)
ON CONFLICT (org_type) DO NOTHING`); err != nil {
t.Fatalf("ensure system org type: %v", err)
}
insertSystemOrg := func(name string) error {
_, err := tx.ExecContext(ctx,
`INSERT INTO core.organizations (name, org_type, owner_person_id)
VALUES ($1, 'system', $2)`, name, personID)
return err
}
// The shared test database may already hold the System tenant (any app
// boot or systemtenant.Ensure creates it). Either way, exactly one
// system organization can exist: seed one if there is none, then prove
// the next insert is refused.
var existing int
if err := tx.QueryRowContext(ctx,
`SELECT count(*) FROM core.organizations WHERE org_type = 'system'`).Scan(&existing); err != nil {
t.Fatalf("count system orgs: %v", err)
}
if existing == 0 {
if err := insertSystemOrg(uniqueLabel("sys-org-first-")); err != nil {
t.Fatalf("first system organization rejected: %v", err)
}
}
err = insertSystemOrg(uniqueLabel("sys-org-second-"))
assertPgError(t, err, "23505", "uq_organizations_one_system")
}
// Workspace names are unique within an organization, compared without regard
// to case, among workspaces that are not deleted
// (uq_workspaces_org_id_name_ci). The index is partial on live rows, so a
// deleted workspace hands its name back.
func TestWorkspaces_DuplicateNameRejectedCaseInsensitively(t *testing.T) {
database := newSchemaHardeningTestDB(t)
ctx := context.Background()
personID := mustInsertPerson(t, ctx, database, "ws-name-person")
orgID := mustInsertOrg(t, ctx, database, "ws-name-org", personID)
otherOrgID := mustInsertOrg(t, ctx, database, "ws-name-other-org", personID)
name := uniqueLabel("Ws-Name-")
insert := func(orgID, name string) error {
_, err := database.ExecContext(ctx,
`INSERT INTO core.workspaces (org_id, name) VALUES ($1, $2)`, orgID, name)
return err
}
if err := insert(orgID, name); err != nil {
t.Fatalf("first workspace rejected: %v", err)
}
// Same name, different case, same organization: refused.
err := insert(orgID, strings.ToUpper(name))
assertPgError(t, err, "23505", "uq_workspaces_org_id_name_ci")
// The same name in a different organization is unaffected: the guard is
// scoped to the organization, not global.
if err := insert(otherOrgID, name); err != nil {
t.Errorf("same name in another organization rejected: %v", err)
}
// Deleting the live workspace frees its name.
if _, err := database.ExecContext(ctx,
`UPDATE core.workspaces SET status = 'deleted' WHERE org_id = $1 AND lower(name) = lower($2)`,
orgID, name); err != nil {
t.Fatalf("soft-delete: %v", err)
}
if err := insert(orgID, name); err != nil {
t.Errorf("name not reusable after the holder was deleted: %v", err)
}
}
// Plan ladder names are unique, compared without regard to case
// (uq_plan_ladders_name_ci): ladder names now lead every catalog and
// composite surface, so two ladders sharing one are indistinguishable.
func TestPlanLadders_DuplicateNameRejectedCaseInsensitively(t *testing.T) {
database := newSchemaHardeningTestDB(t)
ctx := context.Background()
name := uniqueLabel("Ladder-Name-")
insert := func(name string) error {
_, err := database.ExecContext(ctx,
`INSERT INTO core.plan_ladders (name) VALUES ($1)`, name)
return err
}
if err := insert(name); err != nil {
t.Fatalf("first ladder rejected: %v", err)
}
err := insert(strings.ToUpper(name))
assertPgError(t, err, "23505", "uq_plan_ladders_name_ci")
}
// Role names are keys (ui-vocabulary): authorization code names `owner`,
// `admin`, and `platform_admin` literally, so the value has to identify one
// row. Migration 00014 states in the schema what used to hold by convention:
// system roles (org_id IS NULL) are unique by name globally, custom roles are
// unique by name within their organization, both compared without regard to
// case. Everything runs in one rolled-back transaction, because a leaked
// second system role would be visible to every later package on the shared
// test database.
func TestRoles_DuplicateRoleNamesRejectedPerScope(t *testing.T) {
database := newSchemaHardeningTestDB(t)
ctx := context.Background()
personID := mustInsertPerson(t, ctx, database, "role-name-person")
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback() //nolint:errcheck
insertOrg := func(label string) string {
t.Helper()
var orgID string
if err := tx.QueryRowContext(ctx,
`INSERT INTO core.organizations (name, org_type, owner_person_id)
VALUES ($1, 'personal', $2) RETURNING org_id`,
uniqueLabel(label), personID).Scan(&orgID); err != nil {
t.Fatalf("insert org: %v", err)
}
return orgID
}
// Each insert runs inside its own savepoint: a rejected insert aborts the
// enclosing transaction, and the test has more to prove afterwards.
sp := 0
insertRole := func(orgID any, roleName string, isSystem bool) error {
t.Helper()
sp++
name := fmt.Sprintf("role_probe_%d", sp)
if _, err := tx.ExecContext(ctx, "SAVEPOINT "+name); err != nil {
t.Fatalf("savepoint: %v", err)
}
_, err := tx.ExecContext(ctx,
`INSERT INTO core.roles (org_id, role_name, display_name, is_system, permissions)
VALUES ($1, $2, $3, $4, '{}')`,
orgID, roleName, roleName, isSystem)
if err != nil {
if _, rbErr := tx.ExecContext(ctx, "ROLLBACK TO SAVEPOINT "+name); rbErr != nil {
t.Fatalf("rollback to savepoint: %v", rbErr)
}
}
return err
}
// (a) System roles: `owner` is migration-seeded, so a second one is the
// duplicate the index has to refuse. Case is not a difference.
err = insertRole(nil, "Owner", true)
assertPgError(t, err, "23505", "uq_roles_system_role_name")
// (b) Custom roles: unique within the organization, ignoring case.
orgA := insertOrg("role-name-org-a-")
orgB := insertOrg("role-name-org-b-")
if err := insertRole(orgA, "Editor", false); err != nil {
t.Fatalf("first custom role rejected: %v", err)
}
err = insertRole(orgA, "editor", false)
assertPgError(t, err, "23505", "uq_roles_org_role_name")
// (c) The guard is scoped to the organization, not global: another
// organization may name a role `Editor` too.
if err := insertRole(orgB, "Editor", false); err != nil {
t.Errorf("same custom role name in another organization rejected: %v", err)
}
// A custom role never collides with a system role: the two indexes have
// disjoint predicates (org_id IS NULL versus IS NOT NULL).
if err := insertRole(orgA, "owner", false); err != nil {
t.Errorf("custom role sharing a system role's name rejected: %v", err)
}
}