Move settled investigation records into `status/explorations/<topic>-<date>/` with README indexes, promote durable conventions to `docs/`, and add `docs/identifiers.md` plus upstream Doc 46 person-name provenance documents. Update cross-references and archive resolved issues.
306 lines
11 KiB
Go
306 lines
11 KiB
Go
package db_test
|
|
|
|
// DB-backed tests for migration 00012_entity_keys.sql (entity-keys change;
|
|
// docs/identifiers.md §2 to §4). What is under test is the column
|
|
// contract itself, one rule for seven tables: a nullable `key`, the ratified
|
|
// grammar `^[a-z][a-z0-9_]*$` at most 64 characters, and uniqueness within
|
|
// the smallest namespace the entity inhabits (the table for a root entity,
|
|
// the parent for a child). The two constants the console writes for itself --
|
|
// the System tenant's `system` and each organization's default pool's
|
|
// `default` -- are proven through the code paths that create those rows, not
|
|
// by asserting on rows a fixture inserted.
|
|
//
|
|
// Harness is schema_hardening_test.go's: skip without TEST_DATABASE_URL,
|
|
// migrate via db.RunMigrations, reuse its mustInsert* fixture helpers,
|
|
// assertPgError, and uniqueLabel.
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"testing"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/provisioning"
|
|
)
|
|
|
|
// columnExists reports whether core.<table>.<column> is present.
|
|
func columnExists(t *testing.T, ctx context.Context, database *sql.DB, table, column string) bool {
|
|
t.Helper()
|
|
var exists bool
|
|
if err := database.QueryRowContext(ctx,
|
|
`SELECT EXISTS(
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema = 'core' AND table_name = $1 AND column_name = $2
|
|
)`, table, column).Scan(&exists); err != nil {
|
|
t.Fatalf("column probe %s.%s: %v", table, column, err)
|
|
}
|
|
return exists
|
|
}
|
|
|
|
// TestEntityKeys_ColumnsPresentAndOldNamesGone covers §2 (which entities carry
|
|
// a key) and §7 (the three slug columns and the plan ladder's ladder_key
|
|
// become `key`, they are not dropped and re-added under the old spelling).
|
|
func TestEntityKeys_ColumnsPresentAndOldNamesGone(t *testing.T) {
|
|
database := newSchemaHardeningTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
keyed := []string{
|
|
"organizations",
|
|
"workspaces",
|
|
"resource_pools",
|
|
"plan_ladders",
|
|
"entitlement_sets",
|
|
"products",
|
|
"prices",
|
|
}
|
|
for _, table := range keyed {
|
|
if !columnExists(t, ctx, database, table, "key") {
|
|
t.Errorf("core.%s has no `key` column", table)
|
|
}
|
|
}
|
|
|
|
// The retired spellings. `slug` named a value derived from a login name
|
|
// or a display name; `ladder_key` was the one-off spelling of the same
|
|
// role the uniform column now carries.
|
|
retired := []struct{ table, column string }{
|
|
{"organizations", "slug"},
|
|
{"workspaces", "slug"},
|
|
{"resource_pools", "slug"},
|
|
{"plan_ladders", "ladder_key"},
|
|
}
|
|
for _, r := range retired {
|
|
if columnExists(t, ctx, database, r.table, r.column) {
|
|
t.Errorf("core.%s still has the retired column %q", r.table, r.column)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEntityKeys_GrammarCheckRejectsAndAccepts covers §4: one grammar for
|
|
// every key column, so one validator and one error message. `Bad-Key` fails
|
|
// it twice over (an upper-case letter and a hyphen); `demo_ladder` is the
|
|
// shape the demo seed writes.
|
|
func TestEntityKeys_GrammarCheckRejectsAndAccepts(t *testing.T) {
|
|
database := newSchemaHardeningTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
insertLadder := func(key any) error {
|
|
_, err := database.ExecContext(ctx,
|
|
`INSERT INTO core.plan_ladders (name, key) VALUES ($1, $2)`,
|
|
uniqueLabel("keys-grammar-ladder-"), key)
|
|
return err
|
|
}
|
|
|
|
err := insertLadder("Bad-Key")
|
|
assertPgError(t, err, "23514", "chk_plan_ladders_key_grammar")
|
|
|
|
accepted := uniqueLabel("demo_ladder_")
|
|
if err := insertLadder(accepted); err != nil {
|
|
t.Fatalf("grammar rejected a conforming key %q: %v", accepted, err)
|
|
}
|
|
// The literal the demo seed uses, spelled exactly, must pass the CHECK.
|
|
// It is inserted and rolled back so the seeded row is not claimed here.
|
|
tx, err := database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
t.Fatalf("begin: %v", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO core.plan_ladders (name, key) VALUES ($1, 'demo_ladder')`,
|
|
uniqueLabel("keys-grammar-demo-")); err != nil {
|
|
t.Errorf("grammar rejected the demo seed's key `demo_ladder`: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestEntityKeys_RootUniquenessAndNullsCoexist covers §3 (root entities are
|
|
// `UNIQUE (key)`) and §4 (Postgres treats NULLs as distinct, so rows without
|
|
// a key never conflict and no partial index is needed).
|
|
func TestEntityKeys_RootUniquenessAndNullsCoexist(t *testing.T) {
|
|
database := newSchemaHardeningTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
personID := mustInsertPerson(t, ctx, database, "keys-org-person")
|
|
key := uniqueLabel("keys_org_")
|
|
|
|
insertOrg := func(k any) error {
|
|
_, err := database.ExecContext(ctx,
|
|
`INSERT INTO core.organizations (name, org_type, owner_person_id, key)
|
|
VALUES ($1, 'personal', $2, $3)`,
|
|
uniqueLabel("keys-org-"), personID, k)
|
|
return err
|
|
}
|
|
|
|
if err := insertOrg(key); err != nil {
|
|
t.Fatalf("first keyed organization rejected: %v", err)
|
|
}
|
|
assertPgError(t, insertOrg(key), "23505", "uq_organizations_key")
|
|
|
|
// Two organizations with no key at all sit side by side.
|
|
if err := insertOrg(nil); err != nil {
|
|
t.Fatalf("first key-less organization rejected: %v", err)
|
|
}
|
|
if err := insertOrg(nil); err != nil {
|
|
t.Fatalf("second key-less organization rejected -- NULLs must not conflict: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestEntityKeys_WorkspaceKeyScopedToOrganization covers §3's child rule: the
|
|
// same key is free in a second organization, and taken twice in one.
|
|
func TestEntityKeys_WorkspaceKeyScopedToOrganization(t *testing.T) {
|
|
database := newSchemaHardeningTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
personID := mustInsertPerson(t, ctx, database, "keys-ws-person")
|
|
orgA := mustInsertOrg(t, ctx, database, uniqueLabel("keys-ws-org-a-"), personID)
|
|
orgB := mustInsertOrg(t, ctx, database, uniqueLabel("keys-ws-org-b-"), personID)
|
|
key := uniqueLabel("keys_ws_")
|
|
|
|
insertWorkspace := func(orgID string, k any) error {
|
|
_, err := database.ExecContext(ctx,
|
|
`INSERT INTO core.workspaces (org_id, name, key) VALUES ($1, $2, $3)`,
|
|
orgID, uniqueLabel("keys-ws-"), k)
|
|
return err
|
|
}
|
|
|
|
if err := insertWorkspace(orgA, key); err != nil {
|
|
t.Fatalf("first keyed workspace rejected: %v", err)
|
|
}
|
|
assertPgError(t, insertWorkspace(orgA, key), "23505", "uq_workspaces_org_id_key")
|
|
|
|
if err := insertWorkspace(orgB, key); err != nil {
|
|
t.Fatalf("the same key in a second organization was rejected; the scope is (org_id, key): %v", err)
|
|
}
|
|
|
|
if err := insertWorkspace(orgA, nil); err != nil {
|
|
t.Fatalf("first key-less workspace rejected: %v", err)
|
|
}
|
|
if err := insertWorkspace(orgA, nil); err != nil {
|
|
t.Fatalf("second key-less workspace rejected -- NULLs must not conflict: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestEntityKeys_PriceKeyScopedToProduct covers §3 for the second child
|
|
// relation: a price's namespace is its product, which is what makes a key
|
|
// like `monthly` usable on every product at once.
|
|
func TestEntityKeys_PriceKeyScopedToProduct(t *testing.T) {
|
|
database := newSchemaHardeningTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
insertProduct := func() string {
|
|
t.Helper()
|
|
var productID string
|
|
if err := database.QueryRowContext(ctx,
|
|
`INSERT INTO core.products (name) VALUES ($1) RETURNING product_id`,
|
|
uniqueLabel("keys-price-product-")).Scan(&productID); err != nil {
|
|
t.Fatalf("insert product: %v", err)
|
|
}
|
|
return productID
|
|
}
|
|
productA := insertProduct()
|
|
productB := insertProduct()
|
|
key := uniqueLabel("keys_price_")
|
|
|
|
insertPrice := func(productID string, k any) error {
|
|
_, err := database.ExecContext(ctx,
|
|
`INSERT INTO core.prices (product_id, currency, unit_amount, key)
|
|
VALUES ($1, 'usd', 1000, $2)`, productID, k)
|
|
return err
|
|
}
|
|
|
|
if err := insertPrice(productA, key); err != nil {
|
|
t.Fatalf("first keyed price rejected: %v", err)
|
|
}
|
|
assertPgError(t, insertPrice(productA, key), "23505", "uq_prices_product_id_key")
|
|
|
|
if err := insertPrice(productB, key); err != nil {
|
|
t.Fatalf("the same key on a second product was rejected; the scope is (product_id, key): %v", err)
|
|
}
|
|
|
|
if err := insertPrice(productA, nil); err != nil {
|
|
t.Fatalf("first key-less price rejected: %v", err)
|
|
}
|
|
if err := insertPrice(productA, nil); err != nil {
|
|
t.Fatalf("second key-less price rejected -- NULLs must not conflict: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestEntityKeys_SystemTenantCarriesSystemKey covers §4's first constant. The
|
|
// key is written by EnsureSystemOrganization, the one query that creates the
|
|
// singleton, so this drives that query rather than asserting on a fixture
|
|
// row. Everything runs in a rolled-back transaction for the reason
|
|
// TestOrganizations_SecondSystemOrganizationRejected documents: a leaked
|
|
// system organization would be adopted by systemtenant.Ensure later.
|
|
//
|
|
// A database that already holds the System tenant (an app boot, or an earlier
|
|
// package's systemtenant.Ensure) cannot have a second one inserted, so the
|
|
// assertion falls back to the row that is there -- which must carry the same
|
|
// key, whether migration 00012 backfilled it or the query wrote it.
|
|
func TestEntityKeys_SystemTenantCarriesSystemKey(t *testing.T) {
|
|
database := newSchemaHardeningTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
personID := mustInsertPerson(t, ctx, database, "keys-sys-person")
|
|
|
|
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, never migration-seeded; the FK
|
|
// on organizations.org_type needs it 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)
|
|
}
|
|
|
|
orgQ := organization.New(tx)
|
|
if err := orgQ.EnsureSystemOrganization(ctx, organization.EnsureSystemOrganizationParams{
|
|
Name: uniqueLabel("keys-sys-org-"),
|
|
OrgType: "system",
|
|
OwnerPersonID: personID,
|
|
}); err != nil {
|
|
t.Fatalf("EnsureSystemOrganization: %v", err)
|
|
}
|
|
|
|
org, err := orgQ.GetSystemOrganization(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetSystemOrganization: %v", err)
|
|
}
|
|
if !org.Key.Valid || org.Key.String != "system" {
|
|
t.Errorf("System tenant key = %v, want %q", org.Key, "system")
|
|
}
|
|
}
|
|
|
|
// TestEntityKeys_DefaultPoolCarriesDefaultKey covers §4's second constant.
|
|
// The default pool is created by CreateWorkspaceWithPrimaryAssignment, the
|
|
// one path that creates it, so this drives that path; the key is scoped to
|
|
// the organization, so every organization has its own `default`.
|
|
func TestEntityKeys_DefaultPoolCarriesDefaultKey(t *testing.T) {
|
|
database := newSchemaHardeningTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
personID := mustInsertPerson(t, ctx, database, "keys-pool-person")
|
|
orgID := mustInsertOrg(t, ctx, database, uniqueLabel("keys-pool-org-"), personID)
|
|
|
|
tx, err := database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
t.Fatalf("begin: %v", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
result, err := provisioning.CreateWorkspaceWithPrimaryAssignment(ctx, tx, orgID, uniqueLabel("keys-pool-ws-"))
|
|
if err != nil {
|
|
t.Fatalf("CreateWorkspaceWithPrimaryAssignment: %v", err)
|
|
}
|
|
if !result.Pool.Key.Valid || result.Pool.Key.String != "default" {
|
|
t.Errorf("default pool key = %v, want %q", result.Pool.Key, "default")
|
|
}
|
|
// The workspace itself gets none: a key is never derived from a display
|
|
// name (§4), and a singleton child is addressed through its parent.
|
|
if result.Workspace.Key.Valid {
|
|
t.Errorf("workspace key = %q, want NULL", result.Workspace.Key.String)
|
|
}
|
|
}
|