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