From c73bb3b807d78e8df9bee30bf2b3c9fb39cea84d Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Sat, 26 Sep 2026 16:48:10 +0700 Subject: [PATCH] feat: add guarded tenant rehome with rollback verification --- docs/TENANT-REHOME.md | 57 ++++ scripts/rehome-tenant.d.mts | 43 +++ scripts/rehome-tenant.mjs | 398 ++++++++++++++++++++++ tests/integration/rehome-tenant.test.ts | 419 ++++++++++++++++++++++++ tests/unit/rehome-tenant.test.ts | 47 +++ 5 files changed, 964 insertions(+) create mode 100644 docs/TENANT-REHOME.md create mode 100644 scripts/rehome-tenant.d.mts create mode 100644 scripts/rehome-tenant.mjs create mode 100644 tests/integration/rehome-tenant.test.ts create mode 100644 tests/unit/rehome-tenant.test.ts diff --git a/docs/TENANT-REHOME.md b/docs/TENANT-REHOME.md new file mode 100644 index 0000000..f12cc1e --- /dev/null +++ b/docs/TENANT-REHOME.md @@ -0,0 +1,57 @@ +# Перенос арендатора Staff + +`scripts/rehome-tenant.mjs` переносит все строки из одного tenant ID в другой в схеме `public`. Скрипт рассчитан на остановленное приложение и запускается только привилегированной ролью БД, способной обходить RLS и изменять таблицы. Строки пароля или подключения в отчёты не выводятся. + +Сначала на восстановленной копии базы выполните dry-run: + +```powershell +$env:DATABASE_REHOME_URL = 'postgresql://…' +node scripts/rehome-tenant.mjs --dry-run --source --target +``` + +Dry-run открывает транзакцию `READ ONLY` и выводит только идентификаторы переноса и количество строк по таблицам. Он не блокирует записи; поэтому результат является снимком для планирования, а не гарантией актуальности на момент apply. На копии и в согласованное окно с остановленным приложением повторите dry-run непосредственно перед переносом. + +## Обязательное подтверждение восстановления + +Apply требует `--backup-evidence <путь>` и читает JSON отчёт. Само имя файла и существование файла не считаются доказательством. JSON должен содержать сведения о зашифрованном off-host `pg_dump -Fc` и успешном restore drill именно этого backup: + +```json +{ + "backup": { + "format": "pg_dump -Fc", + "encrypted": true, + "offHost": true, + "sha256": "<64 hex characters>", + "createdAt": "2026-09-26T08:00:00Z" + }, + "restoreDrill": { + "status": "passed", + "backupSha256": "", + "restoredAt": "2026-09-26T08:30:00Z", + "verifiedAt": "2026-09-26T08:45:00Z", + "isolatedDatabase": "krona_staff_restore_check" + } +} +``` + +Этот файл удостоверяет результаты, предоставленные оператором; скрипт не проверяет доступ к хранилищу, шифрование или пригодность backup самостоятельно. Сохраните исходный backup, хеш и протокол restore drill вне сервера приложения. + +## Apply + +Проверьте dry-run и backup evidence, остановите приложение, затем запускайте в согласованное окно. `--actor` — ULID уполномоченного пользователя Core, не пароль или секрет: + +```powershell +node scripts/rehome-tenant.mjs --apply ` + --source --target --actor ` + --backup-evidence .\evidence\restore-drill.json +``` + +Apply использует одну транзакцию, сериализуемый уровень изоляции, advisory lock и детерминированные `ACCESS EXCLUSIVE` locks всех таблиц `public.staff_*`. Скрипт отказывает, если целевой tenant уже содержит строки, таблица лишена `tenant_id` или FORCE RLS, обнаружен неизвестный пользовательский trigger либо внешний FK, или роль БД не может безопасно выполнить перенос. Существующие FK сохраняются с их определениями и состоянием validation; если проверка после пересоздания не совпала, транзакция откатывается. + +Отчёт содержит количества перенесённых строк и суммы `staff_payroll_ledger.amount_minor` отдельно по валюте. В `staff_audit_log` перенос сам создаёт ровно одну новую запись `tenant.rehomed` под целевым tenant. Поэтому число строк `staff_audit_log` после commit равно исходному числу строк источника плюс одна; по всем остальным таблицам количество равно исходному. Запись аудита включает from/to, исходные количества, суммы реестра, Core actor, correlation ID и SHA-256/время backup и проверки restore. + +При ошибке stdout/stderr не содержит URL подключения, значений строк или текста серверной ошибки: транзакция откатывается, а в stderr печатается только безопасное сообщение и, если есть, пятисимвольный PostgreSQL SQLSTATE. После успешного commit отдельно проверьте `SELECT DISTINCT tenant_id` по каждой staff таблице, количества строк, сумму реестра по валюте и наличие записи `tenant.rehomed`. + +## Интеграционный тест + +Тест `tests/integration/rehome-tenant.test.ts` предназначен только для отдельной пустой PostgreSQL 17 базы, имя которой заканчивается на `_rehome_test`. Он требует `DATABASE_REHOME_TEST_URL` и явное `DATABASE_REHOME_TEST_CONFIRM=I_ACKNOWLEDGE_DISPOSABLE_PG17_DATABASE`, создаёт в ней роль приложения без пароля при необходимости и применяет миграции на чистой схеме. В конце тест очищает таблицы `public.staff_*`; проверка пустоты выполняется до вставки фикстур. Не указывайте production, pilot или общую базу. diff --git a/scripts/rehome-tenant.d.mts b/scripts/rehome-tenant.d.mts new file mode 100644 index 0000000..ad88a0d --- /dev/null +++ b/scripts/rehome-tenant.d.mts @@ -0,0 +1,43 @@ +import type { Client } from 'pg'; + +export interface BackupEvidenceReport { + backup: { + format: 'pg_dump -Fc'; + encrypted: true; + offHost: true; + sha256: string; + createdAt: string; + }; + restoreDrill: { + status: 'passed'; + backupSha256: string; + restoredAt: string; + verifiedAt: string; + isolatedDatabase: string; + }; +} + +export interface RehomeOptions { + mode: 'dry-run' | 'apply'; + source: string; + target: string; + actor?: string; + correlationId?: string; + backupEvidence?: BackupEvidenceReport; +} + +export interface RehomeReport { + mode: 'dry-run' | 'apply'; + source: string; + target: string; + tables: Record; + auditRowsAdded?: 1; + payrollLedger?: Array<{ currency: string; amountMinor: string }>; +} + +export function parseTenantRehomeArgs(args: string[]): Record; +export function validateBackupEvidenceObject( + evidence: unknown, +): BackupEvidenceReport; +export function validateBackupEvidence(filePath: string): Promise; +export function rehomeTenant(client: Client, options: RehomeOptions): Promise; diff --git a/scripts/rehome-tenant.mjs b/scripts/rehome-tenant.mjs new file mode 100644 index 0000000..0d63f58 --- /dev/null +++ b/scripts/rehome-tenant.mjs @@ -0,0 +1,398 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import pg from 'pg'; +import { ulid } from 'ulid'; + +const ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/; +const EXPECTED_TABLES = [ + 'staff_audit_log', + 'staff_attendance_events', + 'staff_daily_metrics', + 'staff_employees', + 'staff_idempotency_keys', + 'staff_inbox', + 'staff_metric_definitions', + 'staff_notification_deliveries', + 'staff_org_unit_terms', + 'staff_org_units', + 'staff_outbox', + 'staff_pay_assignments', + 'staff_pay_rules', + 'staff_payroll_ledger', + 'staff_payroll_lines', + 'staff_payroll_runs', + 'staff_professions', + 'staff_push_subscriptions', + 'staff_standalone_employee_access', + 'staff_sync_change_log', + 'staff_sync_command_inbox', + 'staff_sync_device_checkpoints', + 'staff_sync_devices', + 'staff_timesheet_entries', + 'staff_work_items', + 'staff_work_sessions', + 'staff_payouts', +]; + +class SafeOperationError extends Error {} + +const identifier = (value) => `"${String(value).replaceAll('"', '""')}"`; +const qualified = (schema, table) => `${identifier(schema)}.${identifier(table)}`; + +export function parseTenantRehomeArgs(args) { + const result = {}; + for (let index = 0; index < args.length; index += 1) { + const key = args[index]; + if (key === '--dry-run' || key === '--apply') { + if (result.mode) throw new SafeOperationError('Choose exactly one of --dry-run or --apply.'); + result.mode = key.slice(2); + } else if (['--source', '--target', '--actor', '--backup-evidence'].includes(key)) { + const value = args[index + 1]; + if (!value || value.startsWith('--')) throw new SafeOperationError(`Missing value for ${key}.`); + const name = key.slice(2).replaceAll('-', '_'); + if (result[name]) throw new SafeOperationError(`Duplicate option ${key}.`); + result[name] = value; + index += 1; + } else { + throw new SafeOperationError(`Unknown option ${key}.`); + } + } + + if (!result.mode) throw new SafeOperationError('Specify --dry-run or --apply.'); + for (const name of ['source', 'target']) { + if (!ULID_RE.test(result[name] ?? '')) throw new SafeOperationError(`--${name} must be a valid ULID.`); + } + if (result.source === result.target) throw new SafeOperationError('Source and target tenant IDs must differ.'); + if (result.mode === 'apply') { + if (!ULID_RE.test(result.actor ?? '')) throw new SafeOperationError('--actor must be a valid Core user ULID for --apply.'); + if (!result.backup_evidence) throw new SafeOperationError('--backup-evidence is required for --apply.'); + } else if (result.actor || result.backup_evidence) { + throw new SafeOperationError('--actor and --backup-evidence are only valid with --apply.'); + } + return result; +} + +export function validateBackupEvidenceObject(evidence) { + const backup = evidence?.backup; + const restore = evidence?.restoreDrill; + const sha256 = /^[a-f0-9]{64}$/i; + const isoDate = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value)); + if ( + backup?.format !== 'pg_dump -Fc' || + backup?.encrypted !== true || + backup?.offHost !== true || + !sha256.test(backup?.sha256 ?? '') || + !isoDate(backup?.createdAt) || + restore?.status !== 'passed' || + !isoDate(restore?.restoredAt) || + !sha256.test(restore?.backupSha256 ?? '') || + backup.sha256.toLowerCase() !== restore.backupSha256.toLowerCase() || + typeof restore?.isolatedDatabase !== 'string' || + restore.isolatedDatabase.trim() === '' || + !isoDate(restore?.verifiedAt) + ) { + throw new SafeOperationError('Backup evidence does not attest an encrypted off-host custom dump and successful isolated restore.'); + } + return { + backup: { + format: 'pg_dump -Fc', + encrypted: true, + offHost: true, + sha256: backup.sha256.toLowerCase(), + createdAt: backup.createdAt, + }, + restoreDrill: { + status: 'passed', + backupSha256: restore.backupSha256.toLowerCase(), + restoredAt: restore.restoredAt, + verifiedAt: restore.verifiedAt, + isolatedDatabase: restore.isolatedDatabase, + }, + }; +} + +export async function validateBackupEvidence(filePath) { + let evidence; + try { + evidence = JSON.parse(await readFile(path.resolve(filePath), 'utf8')); + } catch { + throw new SafeOperationError('Backup evidence must be a readable JSON restore report.'); + } + return validateBackupEvidenceObject(evidence); +} + +async function discoverTenantTables(client) { + const result = await client.query(` + SELECT c.relname AS table_name, + c.relrowsecurity AS rls_enabled, + c.relforcerowsecurity AS rls_forced, + pg_get_userbyid(c.relowner) AS table_owner, + EXISTS ( + SELECT 1 FROM pg_attribute a + WHERE a.attrelid = c.oid AND a.attname = 'tenant_id' + AND a.attnum > 0 AND NOT a.attisdropped + ) AS has_tenant_id + 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_' + ORDER BY c.relname + `); + const tables = result.rows; + if (tables.length === 0) throw new SafeOperationError('No public staff tables found.'); + const names = new Set(tables.map((table) => table.table_name)); + const missing = EXPECTED_TABLES.filter((table) => !names.has(table)); + if (missing.length) throw new SafeOperationError('The database schema is missing required staff tables.'); + if (tables.some((table) => !table.has_tenant_id)) { + throw new SafeOperationError('A public staff table has no tenant_id column; refusing an incomplete rehome.'); + } + if (tables.some((table) => !table.rls_enabled || !table.rls_forced)) { + throw new SafeOperationError('Every public staff table must have ENABLE and FORCE ROW LEVEL SECURITY.'); + } + return tables; +} + +async function assertAdministrator(client, tables) { + const result = await client.query(` + SELECT current_user AS role_name, + r.rolsuper AS is_superuser, + r.rolbypassrls AS bypasses_rls + FROM pg_roles r + WHERE r.rolname = current_user + `); + const role = result.rows[0]; + if (!role || role.role_name === 'krona_staff_app' || (!role.is_superuser && !role.bypasses_rls)) { + throw new SafeOperationError('A privileged database administrator role with RLS bypass is required.'); + } + if (!role.is_superuser && tables.some((table) => table.table_owner !== role.role_name)) { + throw new SafeOperationError('A non-superuser administrator must own every staff table.'); + } +} + +async function assertSafeTriggersAndForeignKeys(client, tables) { + const names = tables.map((table) => table.table_name); + const triggers = await client.query( + `SELECT c.relname AS table_name, t.tgname AS trigger_name + FROM pg_trigger t + JOIN pg_class c ON c.oid = t.tgrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relname = ANY($1::text[]) + AND NOT t.tgisinternal + ORDER BY c.relname, t.tgname`, + [names], + ); + if (triggers.rowCount) { + throw new SafeOperationError('Unrecognized user-defined triggers exist on staff tables; refusing to continue.'); + } + + const fks = await client.query(` + SELECT con.conname AS constraint_name, + src_ns.nspname AS source_schema, + src.relname AS source_table, + dst_ns.nspname AS target_schema, + dst.relname AS target_table, + pg_get_constraintdef(con.oid, true) AS definition, + con.convalidated AS is_validated + FROM pg_constraint con + JOIN pg_class src ON src.oid = con.conrelid + JOIN pg_namespace src_ns ON src_ns.oid = src.relnamespace + JOIN pg_class dst ON dst.oid = con.confrelid + JOIN pg_namespace dst_ns ON dst_ns.oid = dst.relnamespace + WHERE con.contype = 'f' + AND (src_ns.nspname = 'public' AND src.relname = ANY($1::text[]) + OR dst_ns.nspname = 'public' AND dst.relname = ANY($1::text[])) + ORDER BY src_ns.nspname, src.relname, con.conname + `, [names]); + if (fks.rows.some((fk) => + fk.source_schema !== 'public' || fk.target_schema !== 'public' || + !names.includes(fk.source_table) || !names.includes(fk.target_table) + )) { + throw new SafeOperationError('A foreign key crosses the staff tenant-table boundary; refusing an incomplete rehome.'); + } + return fks.rows; +} + +async function tableCounts(client, tables, tenantId) { + const counts = {}; + for (const { table_name: table } of tables) { + const { rows } = await client.query( + `SELECT count(*)::text AS row_count FROM ${qualified('public', table)} WHERE tenant_id = $1`, + [tenantId], + ); + counts[table] = rows[0].row_count; + } + return counts; +} + +async function ledgerTotals(client, tenantId) { + const result = await client.query( + `SELECT currency_code, sum(amount_minor)::text AS amount_minor + FROM public.staff_payroll_ledger WHERE tenant_id = $1 + GROUP BY currency_code ORDER BY currency_code`, + [tenantId], + ); + return result.rows.map(({ currency_code, amount_minor }) => ({ currency: currency_code, amountMinor: amount_minor })); +} + +async function lockTables(client, tables) { + for (const { table_name: table } of tables) { + await client.query(`LOCK TABLE ${qualified('public', table)} IN ACCESS EXCLUSIVE MODE`); + } +} + +export async function rehomeTenant(client, options) { + const { source, target, mode } = options; + if (!ULID_RE.test(source ?? '') || !ULID_RE.test(target ?? '') || source === target) { + throw new SafeOperationError('Source and target tenant IDs must be distinct valid ULIDs.'); + } + if (mode !== 'dry-run' && mode !== 'apply') throw new SafeOperationError('Mode must be dry-run or apply.'); + if (mode === 'apply') { + if (!ULID_RE.test(options.actor ?? '')) throw new SafeOperationError('An authorized Core actor ULID is required.'); + if (!options.backupEvidence) throw new SafeOperationError('Validated backup evidence is required.'); + options.backupEvidence = validateBackupEvidenceObject(options.backupEvidence); + } + + await client.query(mode === 'dry-run' ? 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY' : 'BEGIN ISOLATION LEVEL SERIALIZABLE'); + let committed = false; + try { + if (mode === 'apply') { + await client.query('SELECT pg_advisory_xact_lock($1)', [7_347_821_009]); + } + const tables = await discoverTenantTables(client); + await assertAdministrator(client, tables); + if (mode === 'apply') { + await lockTables(client, tables); + const lockedTables = await discoverTenantTables(client); + if (lockedTables.map((table) => table.table_name).join('\n') !== tables.map((table) => table.table_name).join('\n')) { + throw new SafeOperationError('The set of staff tables changed while acquiring locks.'); + } + } + + const sourceCounts = await tableCounts(client, tables, source); + const targetCounts = await tableCounts(client, tables, target); + const sourceTotal = Object.values(sourceCounts).reduce((total, count) => total + BigInt(count), 0n); + const targetTotal = Object.values(targetCounts).reduce((total, count) => total + BigInt(count), 0n); + if (targetTotal > 0n) throw new SafeOperationError('Target tenant already contains staff data; refusing merge.'); + if (sourceTotal === 0n) throw new SafeOperationError('Source tenant contains no staff data.'); + + const report = { mode, source, target, tables: sourceCounts }; + if (mode === 'dry-run') { + await client.query('COMMIT'); + committed = true; + return report; + } + + const foreignKeys = await assertSafeTriggersAndForeignKeys(client, tables); + const ledgerBefore = await ledgerTotals(client, source); + for (const fk of foreignKeys) { + await client.query( + `ALTER TABLE ${qualified(fk.source_schema, fk.source_table)} DROP CONSTRAINT ${identifier(fk.constraint_name)}`, + ); + } + for (const { table_name: table } of tables) { + await client.query( + `UPDATE ${qualified('public', table)} SET tenant_id = $1 WHERE tenant_id = $2`, + [target, source], + ); + } + for (const fk of foreignKeys) { + let definition = fk.definition; + if (!fk.is_validated && !/\bNOT VALID\s*$/i.test(definition)) definition += ' NOT VALID'; + await client.query( + `ALTER TABLE ${qualified(fk.source_schema, fk.source_table)} ADD CONSTRAINT ${identifier(fk.constraint_name)} ${definition}`, + ); + } + const restoredForeignKeys = await assertSafeTriggersAndForeignKeys(client, tables); + const foreignKeySignature = (fk) => [ + fk.source_schema, fk.source_table, fk.constraint_name, fk.target_schema, + fk.target_table, fk.definition, fk.is_validated, + ].join('\u0000'); + if ( + foreignKeys.map(foreignKeySignature).join('\n') !== + restoredForeignKeys.map(foreignKeySignature).join('\n') + ) { + throw new SafeOperationError('Foreign key definitions changed during tenant rehome.'); + } + + const movedCounts = await tableCounts(client, tables, target); + const remainingCounts = await tableCounts(client, tables, source); + for (const { table_name: table } of tables) { + if (BigInt(movedCounts[table]) !== BigInt(sourceCounts[table]) || BigInt(remainingCounts[table]) !== 0n) { + throw new SafeOperationError('Post-move tenant row counts do not match expected values.'); + } + } + const ledgerAfter = await ledgerTotals(client, target); + if (JSON.stringify(ledgerAfter) !== JSON.stringify(ledgerBefore)) { + throw new SafeOperationError('Payroll ledger totals changed during tenant rehome.'); + } + const actor = options.actor; + const correlationId = options.correlationId ?? ulid(); + if (!ULID_RE.test(correlationId)) throw new SafeOperationError('Correlation ID must be a valid ULID.'); + await client.query( + `INSERT INTO public.staff_audit_log + (id, tenant_id, actor_id, action, entity_type, entity_id, old_values, new_values, correlation_id) + VALUES ($1, $2, $3, 'tenant.rehomed', 'tenant_rehome', NULL, $4::jsonb, $5::jsonb, $6)`, + [ulid(), target, actor, + JSON.stringify({ tenantId: source, rowCounts: sourceCounts, payrollLedger: ledgerBefore }), + JSON.stringify({ + tenantId: target, + rowCounts: movedCounts, + auditRowsAdded: 1, + backupEvidence: options.backupEvidence, + }), correlationId], + ); + const actualAuditCount = BigInt((await tableCounts(client, [{ table_name: 'staff_audit_log' }], target)).staff_audit_log); + if (actualAuditCount !== BigInt(movedCounts.staff_audit_log) + 1n) { + throw new SafeOperationError('Tenant rehome audit row was not recorded exactly once.'); + } + movedCounts.staff_audit_log = actualAuditCount.toString(); + report.tables = movedCounts; + report.auditRowsAdded = 1; + report.payrollLedger = ledgerAfter; + await client.query('COMMIT'); + committed = true; + return report; + } catch (error) { + if (!committed) await client.query('ROLLBACK').catch(() => undefined); + throw error; + } +} + +function parseCli(args) { + const options = parseTenantRehomeArgs(args); + const connectionString = process.env.DATABASE_REHOME_URL ?? process.env.DATABASE_MIGRATION_URL; + if (!connectionString) throw new SafeOperationError('Set DATABASE_REHOME_URL or DATABASE_MIGRATION_URL.'); + return { ...options, connectionString }; +} + +async function main() { + let options; + let client; + try { + options = parseCli(process.argv.slice(2)); + if (options.mode === 'apply') options.backupEvidence = await validateBackupEvidence(options.backup_evidence); + client = new pg.Client({ connectionString: options.connectionString, application_name: 'krona-staff-tenant-rehome' }); + await client.connect(); + const report = await rehomeTenant(client, { + source: options.source, + target: options.target, + actor: options.actor, + mode: options.mode, + backupEvidence: options.backupEvidence, + }); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + } catch (error) { + const code = typeof error?.code === 'string' && /^[A-Z0-9]{5}$/.test(error.code) ? ` (database code ${error.code})` : ''; + const reason = error instanceof SafeOperationError ? ` ${error.message}` : ''; + process.stderr.write(`Tenant rehome stopped.${reason}${code} No connection details or row contents were printed.\n`); + process.exitCode = 1; + } finally { + await client?.end().catch(() => undefined); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + await main(); +} diff --git a/tests/integration/rehome-tenant.test.ts b/tests/integration/rehome-tenant.test.ts new file mode 100644 index 0000000..aa46efe --- /dev/null +++ b/tests/integration/rehome-tenant.test.ts @@ -0,0 +1,419 @@ +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); + }); +}); diff --git a/tests/unit/rehome-tenant.test.ts b/tests/unit/rehome-tenant.test.ts new file mode 100644 index 0000000..292f39a --- /dev/null +++ b/tests/unit/rehome-tenant.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { + parseTenantRehomeArgs, + validateBackupEvidenceObject, +} from '../../scripts/rehome-tenant.mjs'; + +const source = '01J00000000000000000000001'; +const target = '01J00000000000000000000002'; +const actor = '01J00000000000000000000003'; +const sha256 = 'a'.repeat(64); + +describe('tenant rehome argument and evidence validation', () => { + it('requires explicit apply mode and a backup evidence path', () => { + expect(() => parseTenantRehomeArgs(['--source', source, '--target', target])).toThrow('--dry-run or --apply'); + expect(() => parseTenantRehomeArgs(['--apply', '--source', source, '--target', target, '--actor', actor])) + .toThrow('--backup-evidence is required'); + }); + + it('accepts only evidence with encrypted off-host backup and a matching successful restore', () => { + const fullEvidence = { + backup: { + format: 'pg_dump -Fc' as const, + encrypted: true as const, + offHost: true as const, + sha256, + createdAt: '2026-09-26T08:00:00Z', + }, + restoreDrill: { + status: 'passed' as const, + backupSha256: sha256, + restoredAt: '2026-09-26T08:30:00Z', + verifiedAt: '2026-09-26T08:45:00Z', + isolatedDatabase: 'krona_staff_restore_check', + }, + }; + expect(validateBackupEvidenceObject(fullEvidence)).toEqual(fullEvidence); + expect(() => validateBackupEvidenceObject({ + backupSha256: sha256, + backupCreatedAt: '2026-09-26T08:00:00Z', + restoreVerifiedAt: '2026-09-26T08:45:00Z', + })).toThrow('does not attest an encrypted off-host custom dump'); + expect(() => validateBackupEvidenceObject({ + ...fullEvidence, + restoreDrill: { ...fullEvidence.restoreDrill, backupSha256: 'b'.repeat(64) }, + })).toThrow('does not attest an encrypted off-host custom dump'); + }); +});