import { readFile, readdir } from 'node:fs/promises'; import path from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import pg from 'pg'; import type { QueryResultRow } from 'pg'; import { ulid } from 'ulid'; import { rehomeTenant } from '../../scripts/rehome-tenant.mjs'; const connectionString = process.env.DATABASE_REHOME_TEST_URL; const describeDatabase = connectionString ? describe : describe.skip; const confirmation = 'I_ACKNOWLEDGE_DISPOSABLE_PG17_DATABASE'; const evidence = { backup: { format: 'pg_dump -Fc', encrypted: true, offHost: true, sha256: 'a'.repeat(64), createdAt: '2026-09-26T08:00:00Z', }, restoreDrill: { status: 'passed', backupSha256: 'a'.repeat(64), restoredAt: '2026-09-26T08:30:00Z', verifiedAt: '2026-09-26T08:45:00Z', isolatedDatabase: 'krona_staff_restore_check', }, } as const; const tenantTables = [ 'staff_outbox', 'staff_inbox', 'staff_idempotency_keys', 'staff_audit_log', 'staff_employees', 'staff_attendance_events', 'staff_timesheet_entries', 'staff_org_units', 'staff_org_unit_terms', 'staff_metric_definitions', 'staff_daily_metrics', 'staff_work_items', 'staff_pay_rules', 'staff_pay_assignments', 'staff_work_sessions', 'staff_payroll_runs', 'staff_payroll_lines', 'staff_payouts', 'staff_payroll_ledger', 'staff_standalone_employee_access', 'staff_professions', 'staff_push_subscriptions', 'staff_notification_deliveries', 'staff_sync_devices', 'staff_sync_change_log', 'staff_sync_command_inbox', 'staff_sync_device_checkpoints', ]; interface IdentityRow extends QueryResultRow { database_name: string; version_num: number; is_superuser: boolean; } interface CountRow extends QueryResultRow { count: number; } interface RoleRow extends QueryResultRow { rolsuper: boolean; rolbypassrls: boolean; } interface PresentRow extends QueryResultRow { present: boolean; } interface TableNameRow extends QueryResultRow { relname: string; } interface RowCountRow extends QueryResultRow { n: string; } interface ForeignKeyRow extends QueryResultRow { source_table: string; conname: string; definition: string; convalidated: boolean; } interface RehomeAuditRow extends QueryResultRow { action: string; actor_id: string; old_values: { tenantId: string }; new_values: { tenantId: string; auditRowsAdded: number }; } async function bootstrapDisposableDatabase(client: pg.Client) { if (process.env.DATABASE_REHOME_TEST_CONFIRM !== confirmation) { throw new Error( `Set DATABASE_REHOME_TEST_CONFIRM=${confirmation} only for a disposable test database.`, ); } const identity = await client.query(` SELECT current_database() AS database_name, current_setting('server_version_num')::integer AS version_num, role.rolsuper AS is_superuser FROM pg_roles role WHERE role.rolname = current_user `); const row = identity.rows[0]!; if ( !row.database_name.endsWith('_rehome_test') || row.version_num < 170000 || !row.is_superuser ) { throw new Error( 'Integration tests require a disposable database ending _rehome_test on PostgreSQL 17 and a superuser connection.', ); } const existing = await client.query(` SELECT count(*)::integer AS count FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p') AND left(c.relname, 6) = 'staff_' `); const hasStaffSchema = Number(existing.rows[0]!.count) > 0; const appRole = await client.query( "SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'krona_staff_app'", ); if (appRole.rowCount === 0) { await client.query( 'CREATE ROLE krona_staff_app NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS', ); } else if (appRole.rows[0]!.rolsuper || appRole.rows[0]!.rolbypassrls) { throw new Error('The disposable database app role must be NOSUPERUSER and NOBYPASSRLS.'); } if (!hasStaffSchema) { const migrations = (await readdir(path.resolve('migrations'))) .filter((name) => name.endsWith('.up.sql')) .sort(); for (const migration of migrations) { await client.query(await readFile(path.resolve('migrations', migration), 'utf8')); } } else { const checks: Array<{ table: string; present: boolean }> = []; for (const table of tenantTables) { const result = await client.query( 'SELECT to_regclass($1) IS NOT NULL AS present', [`public.${table}`], ); checks.push({ table, present: result.rows[0]!.present }); } const missing = checks.filter((check) => !check.present); if (missing.length) throw new Error('The disposable database has an incomplete Staff schema.'); } const tableNames = await client.query(` SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p') AND left(c.relname, 6) = 'staff_' `); for (const { relname } of tableNames.rows) { const count = await client.query( `SELECT count(*)::text AS n FROM public."${relname.replaceAll('"', '""')}"`, ); if (BigInt(count.rows[0]!.n) !== 0n) { throw new Error('Use an empty disposable database for the tenant-rehome integration test.'); } } } async function listForeignKeyDefinitions(client: pg.Client) { const result = await client.query( ` SELECT source.relname AS source_table, con.conname, pg_get_constraintdef(con.oid, true) AS definition, con.convalidated FROM pg_constraint con JOIN pg_class source ON source.oid = con.conrelid JOIN pg_namespace n ON n.oid = source.relnamespace WHERE n.nspname = 'public' AND source.relname = ANY($1::text[]) AND con.contype = 'f' ORDER BY source.relname, con.conname `, [tenantTables], ); return result.rows; } describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => { const client = new pg.Client({ connectionString: connectionString!, application_name: 'krona-staff-rehome-integration-test', }); let safeToCleanup = false; const source = ulid(); const target = ulid(); const other = ulid(); const actor = ulid(); const ids = Object.fromEntries( [ 'org', 'employee', 'profession', 'metric', 'rule', 'run', 'outbox', 'device', 'attendance', 'timesheet', 'term', 'dailyMetric', 'workItem', 'assignment', 'session', 'line', 'payout', 'ledger', 'access', 'subscription', 'delivery', 'change', 'command', 'checkpoint', 'audit', 'inbox', 'idempotency', 'otherProfession', ].map((key) => [key, ulid()]), ); beforeAll(async () => { await client.connect(); await bootstrapDisposableDatabase(client); safeToCleanup = true; }); afterAll(async () => { if (safeToCleanup) { await client.query('DROP TABLE IF EXISTS public.external_rehome_reference;'); await client.query( 'DROP TRIGGER IF EXISTS rehome_test_unknown_trigger ON public.staff_employees;', ); await client.query('DROP FUNCTION IF EXISTS public.rehome_test_trigger_fn();'); const tables = tenantTables.map((table) => `public."${table}"`).join(', '); await client.query(`TRUNCATE TABLE ${tables} CASCADE`); } await client.end(); }); it('moves every tenant table atomically, preserves keys and totals, and audits one added row', async () => { await client.query('BEGIN'); try { await client.query( `INSERT INTO staff_org_units (id, tenant_id, code, name) VALUES ($1, $2, 'unit_test', 'Fixture unit')`, [ids.org, source], ); await client.query( `INSERT INTO staff_professions (id, tenant_id, code, name) VALUES ($1, $2, 'role_test', 'Fixture role')`, [ids.profession, source], ); await client.query( `INSERT INTO staff_employees (id, tenant_id, profession_id, primary_org_unit_id, personnel_number, display_name) VALUES ($1, $2, $3, $4, 'FIXTURE-1', 'Synthetic fixture')`, [ids.employee, source, ids.profession, ids.org], ); await client.query( `INSERT INTO staff_metric_definitions (id, tenant_id, key, label) VALUES ($1, $2, 'fixture_metric', 'Fixture metric')`, [ids.metric, source], ); await client.query( `INSERT INTO staff_pay_rules (id, tenant_id, code, version, name, rule, effective_from, created_by) VALUES ($1, $2, 'fixture_rule', 1, 'Fixture rule', '{}'::jsonb, '2026-01-01', $3)`, [ids.rule, source, actor], ); await client.query( `INSERT INTO staff_payroll_runs (id, tenant_id, org_unit_id, period_start, period_end, created_by) VALUES ($1, $2, $3, '2026-01-01', '2026-01-31', $4)`, [ids.run, source, ids.org, actor], ); await client.query( `INSERT INTO staff_outbox (id, tenant_id, event_type, event_version, subject_type, subject_id, actor_type, actor_id, payload, correlation_id, occurred_at) VALUES ($1, $2, 'fixture.event', 1, 'staff.Employee', $3, 'user', $4, '{}'::jsonb, $5, now())`, [ids.outbox, source, ids.employee, actor, ulid()], ); await client.query( `INSERT INTO staff_inbox (tenant_id, event_id, event_type, event_version, correlation_id) VALUES ($1, $2, 'fixture.event', 1, $3)`, [source, ulid(), ulid()], ); await client.query( `INSERT INTO staff_idempotency_keys (tenant_id, key, request_hash, expires_at) VALUES ($1, 'fixture-key', 'synthetic-hash', now() + interval '1 day')`, [source], ); await client.query( `INSERT INTO staff_audit_log (id, tenant_id, actor_id, action, entity_type, correlation_id) VALUES ($1, $2, $3, 'fixture.created', 'fixture', $4)`, [ids.audit, source, actor, ulid()], ); await client.query( `INSERT INTO staff_attendance_events (id, tenant_id, employee_id, event_type, source, source_event_id, occurred_at, actor_id, correlation_id) VALUES ($1, $2, $3, 'check_in', 'manual_entry', 'fixture-attendance', now(), $4, $5)`, [ids.attendance, source, ids.employee, actor, ulid()], ); await client.query( `INSERT INTO staff_timesheet_entries (id, tenant_id, employee_id, work_date, updated_by) VALUES ($1, $2, $3, '2026-01-01', $4)`, [ulid(), source, ids.employee, actor], ); await client.query( `INSERT INTO staff_org_unit_terms (id, tenant_id, org_unit_id, effective_from, created_by) VALUES ($1, $2, $3, '2026-01-01', $4)`, [ids.term, source, ids.org, actor], ); await client.query( `INSERT INTO staff_daily_metrics (id, tenant_id, org_unit_id, business_date, metric_key, value, recorded_by) VALUES ($1, $2, $3, '2026-01-01', 'fixture_metric', 123, $4)`, [ids.dailyMetric, source, ids.org, actor], ); await client.query( `INSERT INTO staff_work_items (id, tenant_id, employee_id, org_unit_id, business_date, item_kind, created_by) VALUES ($1, $2, $3, $4, '2026-01-01', 'fixture_item', $5)`, [ids.workItem, source, ids.employee, ids.org, actor], ); await client.query( `INSERT INTO staff_pay_assignments (id, tenant_id, employee_id, pay_rule_id, effective_from, created_by) VALUES ($1, $2, $3, $4, '2026-01-01', $5)`, [ids.assignment, source, ids.employee, ids.rule, actor], ); await client.query( `INSERT INTO staff_work_sessions (id, tenant_id, employee_id, org_unit_id, business_date) VALUES ($1, $2, $3, $4, '2026-01-01')`, [ids.session, source, ids.employee, ids.org], ); await client.query( `INSERT INTO staff_payroll_lines (id, tenant_id, run_id, employee_id, org_unit_id, business_date, pay_rule_id, rule_code, amount_minor) VALUES ($1, $2, $3, $4, $5, '2026-01-01', $6, 'fixture_rule', 12345)`, [ids.line, source, ids.run, ids.employee, ids.org, ids.rule], ); await client.query( `INSERT INTO staff_payouts (id, tenant_id, employee_id, paid_on, amount_minor, created_by) VALUES ($1, $2, $3, '2026-01-01', 200, $4)`, [ids.payout, source, ids.employee, actor], ); await client.query( `INSERT INTO staff_payroll_ledger (id, tenant_id, employee_id, business_date, kind, amount_minor, currency_code, ref_type, correlation_id, created_by) VALUES ($1, $2, $3, '2026-01-01', 'accrual', 12345, 'RUB', 'payroll_run', $4, $5), ($6, $2, $3, '2026-01-01', 'adjustment', 50, 'KZT', 'manual', $4, $5)`, [ids.ledger, source, ids.employee, ulid(), actor, ulid()], ); await client.query( `INSERT INTO staff_standalone_employee_access (id, tenant_id, employee_id, subject_id, token_hash, token_hint, created_by) VALUES ($1, $2, $3, $4, $5, 'synth', $6)`, [ids.access, source, ids.employee, ulid(), 'a'.repeat(64), actor], ); await client.query( `INSERT INTO staff_push_subscriptions (id, tenant_id, employee_id, endpoint, p256dh, auth) VALUES ($1, $2, $3, 'https://push.invalid/fixture', 'synthetic-key', 'synthetic-auth')`, [ids.subscription, source, ids.employee], ); await client.query( `INSERT INTO staff_notification_deliveries (id, tenant_id, employee_id, outbox_id, endpoint, status) VALUES ($1, $2, $3, $4, 'https://push.invalid/fixture', 'delivered')`, [ids.delivery, source, ids.employee, ids.outbox], ); await client.query( `INSERT INTO staff_sync_devices (id, tenant_id, device_code, name, token_hash, token_hint, created_by, org_unit_id) VALUES ($1, $2, 'fixture-device', 'Synthetic device', $3, 'fixture', $4, $5)`, [ids.device, source, 'b'.repeat(64), actor, ids.org], ); await client.query( `INSERT INTO staff_sync_change_log (id, tenant_id, entity_type, entity_id, operation) VALUES ($1, $2, 'employee', $3, 'upsert')`, [ids.change, source, ids.employee], ); await client.query( `INSERT INTO staff_sync_command_inbox (id, tenant_id, device_id, command_type, idempotency_key, correlation_id, status, created_at) VALUES ($1, $2, $3, 'fixture', 'fixture-command', $4, 'applied', now())`, [ids.command, source, ids.device, ulid()], ); await client.query( `INSERT INTO staff_sync_device_checkpoints (tenant_id, device_id, last_acked_cursor_seq) VALUES ($1, $2, 10)`, [source, ids.device], ); await client.query( `INSERT INTO staff_professions (id, tenant_id, code, name) VALUES ($1, $2, 'other_fixture', 'Other synthetic role')`, [ids.otherProfession, other], ); await client.query('COMMIT'); } catch (error) { await client.query('ROLLBACK'); throw error; } const oldRole = await client.query( "SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'krona_staff_app'", ); expect(oldRole.rows[0]).toEqual({ rolsuper: false, rolbypassrls: false }); await client.query('SET ROLE krona_staff_app'); try { await expect( rehomeTenant(client, { mode: 'apply', source, target, actor, backupEvidence: evidence }), ).rejects.toThrow('privileged database administrator'); } finally { await client.query('RESET ROLE'); } const initialForeignKeys = await listForeignKeyDefinitions(client); const dryRun = await rehomeTenant(client, { mode: 'dry-run', source, target }); expect(Object.keys(dryRun.tables)).toHaveLength(tenantTables.length); for (const table of tenantTables) { expect(dryRun.tables[table]).toBe(table === 'staff_payroll_ledger' ? '2' : '1'); } const occupiedTarget = ulid(); await client.query( `INSERT INTO staff_professions (id, tenant_id, code, name) VALUES ($1, $2, 'occupied_fixture', 'Occupied synthetic target')`, [ulid(), occupiedTarget], ); await expect( rehomeTenant(client, { mode: 'apply', source, target: occupiedTarget, actor, backupEvidence: evidence, }), ).rejects.toThrow('Target tenant already contains staff data'); await client.query(` CREATE FUNCTION public.rehome_test_trigger_fn() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$; CREATE TRIGGER rehome_test_unknown_trigger BEFORE UPDATE ON public.staff_employees FOR EACH ROW EXECUTE FUNCTION public.rehome_test_trigger_fn(); `); await expect( rehomeTenant(client, { mode: 'apply', source, target, actor, backupEvidence: evidence }), ).rejects.toThrow('Unrecognized user-defined triggers'); await client.query( 'DROP TRIGGER rehome_test_unknown_trigger ON public.staff_employees; DROP FUNCTION public.rehome_test_trigger_fn();', ); await client.query( 'CREATE TABLE public.external_rehome_reference (employee_id VARCHAR(26) REFERENCES public.staff_employees(id));', ); await expect( rehomeTenant(client, { mode: 'apply', source, target, actor, backupEvidence: evidence }), ).rejects.toThrow('foreign key crosses the staff tenant-table boundary'); await client.query('DROP TABLE public.external_rehome_reference;'); await expect( rehomeTenant(client, { mode: 'apply', source, target, actor, backupEvidence: evidence, correlationId: 'invalid', }), ).rejects.toThrow('Correlation ID must be a valid ULID.'); for (const table of tenantTables) { const sourceRows = await client.query( `SELECT count(*)::text AS n FROM public."${table}" WHERE tenant_id = $1`, [source], ); expect(sourceRows.rows[0]!.n, `${table} survives rollback`).toBe( table === 'staff_payroll_ledger' ? '2' : '1', ); } expect(await listForeignKeyDefinitions(client)).toEqual(initialForeignKeys); const result = await rehomeTenant(client, { mode: 'apply', source, target, actor, backupEvidence: evidence, }); expect(result.auditRowsAdded).toBe(1); expect(result.tables.staff_audit_log).toBe('2'); expect(result.payrollLedger).toEqual([ { currency: 'KZT', amountMinor: '50' }, { currency: 'RUB', amountMinor: '12345' }, ]); for (const table of tenantTables) { const sourceRows = await client.query( `SELECT count(*)::text AS n FROM public."${table}" WHERE tenant_id = $1`, [source], ); expect(sourceRows.rows[0]!.n, `${table} source rows`).toBe('0'); const targetRows = await client.query( `SELECT count(*)::text AS n FROM public."${table}" WHERE tenant_id = $1`, [target], ); expect(targetRows.rows[0]!.n, `${table} target rows`).toBe(result.tables[table]); } const untouchedOther = await client.query( 'SELECT count(*)::text AS n FROM staff_professions WHERE tenant_id = $1', [other], ); expect(untouchedOther.rows[0]!.n).toBe('1'); expect(await listForeignKeyDefinitions(client)).toEqual(initialForeignKeys); await client.query('BEGIN'); try { await client.query('SET LOCAL ROLE krona_staff_app'); await client.query( "SELECT set_config('app.tenant_id', $1, true), set_config('app.user_id', $2, true)", [target, actor], ); const visibleToTarget = await client.query( 'SELECT count(*)::text AS n FROM staff_employees', ); expect(visibleToTarget.rows[0]!.n).toBe('1'); await client.query("SELECT set_config('app.tenant_id', $1, true)", [source]); const visibleToOldTenant = await client.query( 'SELECT count(*)::text AS n FROM staff_employees', ); expect(visibleToOldTenant.rows[0]!.n).toBe('0'); await client.query('COMMIT'); } catch (error) { await client.query('ROLLBACK').catch(() => undefined); throw error; } const rehomeAudit = await client.query( `SELECT action, actor_id, old_values, new_values FROM staff_audit_log WHERE tenant_id = $1 AND action = 'tenant.rehomed'`, [target], ); expect(rehomeAudit.rows).toHaveLength(1); expect(rehomeAudit.rows[0]!.actor_id).toBe(actor); expect(rehomeAudit.rows[0]!.old_values.tenantId).toBe(source); expect(rehomeAudit.rows[0]!.new_values.tenantId).toBe(target); expect(rehomeAudit.rows[0]!.new_values.auditRowsAdded).toBe(1); }); });