Compare commits
2 commits
codex/boot
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d52ecf3261 | ||
|
|
c73bb3b807 |
5 changed files with 993 additions and 0 deletions
59
docs/TENANT-REHOME.md
Normal file
59
docs/TENANT-REHOME.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# Перенос арендатора Staff
|
||||
|
||||
`scripts/rehome-tenant.mjs` переносит все строки из одного tenant ID в другой в схеме `public`. Скрипт рассчитан на остановленное приложение и запускается только привилегированной ролью БД, способной обходить RLS и изменять таблицы. Строки пароля или подключения в отчёты не выводятся.
|
||||
|
||||
CLI по умолчанию выполняет только read-only dry-run; `--dry-run` можно указать явно. Совместимые имена `--from`/`--to` и `--json` принимаются для rehearsal-обвязок; вывод всегда JSON. Сначала на восстановленной копии базы выполните dry-run:
|
||||
|
||||
```powershell
|
||||
$env:DATABASE_ADMIN_URL = 'postgresql://…'
|
||||
node scripts/rehome-tenant.mjs --from <ULID> --to <ULID> --dry-run --json
|
||||
```
|
||||
|
||||
Для совместимости со старыми локальными инструкциями также принимаются `DATABASE_REHOME_URL` и `DATABASE_MIGRATION_URL` как fallback. `--apply` по-прежнему требует явный флаг, actor и проверенный `--backup-evidence`; aliases и default dry-run не разрешают запись сами по себе.
|
||||
|
||||
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": "<same 64 hex characters>",
|
||||
"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 <ULID> --target <ULID> --actor <CORE_USER_ULID> `
|
||||
--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 или общую базу.
|
||||
52
scripts/rehome-tenant.d.mts
Normal file
52
scripts/rehome-tenant.d.mts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
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<string, string>;
|
||||
auditRowsAdded?: 1;
|
||||
payrollLedger?: Array<{ currency: string; amountMinor: string }>;
|
||||
}
|
||||
|
||||
export interface ParsedTenantRehomeArgs {
|
||||
mode: 'dry-run' | 'apply';
|
||||
source: string;
|
||||
target: string;
|
||||
actor?: string;
|
||||
backup_evidence?: string;
|
||||
json?: true;
|
||||
}
|
||||
|
||||
export function parseTenantRehomeArgs(args: string[]): ParsedTenantRehomeArgs;
|
||||
export function validateBackupEvidenceObject(
|
||||
evidence: unknown,
|
||||
): BackupEvidenceReport;
|
||||
export function validateBackupEvidence(filePath: string): Promise<BackupEvidenceReport>;
|
||||
export function rehomeTenant(client: Client, options: RehomeOptions): Promise<RehomeReport>;
|
||||
408
scripts/rehome-tenant.mjs
Normal file
408
scripts/rehome-tenant.mjs
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
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 (key === '--json') {
|
||||
if (result.json) throw new SafeOperationError('Duplicate option --json.');
|
||||
result.json = true;
|
||||
} else if (['--source', '--from', '--target', '--to', '--actor', '--backup-evidence'].includes(key)) {
|
||||
const value = args[index + 1];
|
||||
if (!value || value.startsWith('--')) throw new SafeOperationError(`Missing value for ${key}.`);
|
||||
const name = ({
|
||||
'--source': 'source',
|
||||
'--from': 'source',
|
||||
'--target': 'target',
|
||||
'--to': 'target',
|
||||
'--actor': 'actor',
|
||||
'--backup-evidence': 'backup_evidence',
|
||||
})[key];
|
||||
if (result[name]) throw new SafeOperationError(`Duplicate option for ${name.replaceAll('_', '-')}.`);
|
||||
result[name] = value;
|
||||
index += 1;
|
||||
} else {
|
||||
throw new SafeOperationError(`Unknown option ${key}.`);
|
||||
}
|
||||
}
|
||||
|
||||
result.mode ??= 'dry-run';
|
||||
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_ADMIN_URL ?? process.env.DATABASE_REHOME_URL ?? process.env.DATABASE_MIGRATION_URL;
|
||||
if (!connectionString) throw new SafeOperationError('Set DATABASE_ADMIN_URL, 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();
|
||||
}
|
||||
419
tests/integration/rehome-tenant.test.ts
Normal file
419
tests/integration/rehome-tenant.test.ts
Normal file
|
|
@ -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<IdentityRow>(`
|
||||
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<CountRow>(`
|
||||
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<RoleRow>("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<PresentRow>('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<TableNameRow>(`
|
||||
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<RowCountRow>(`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<ForeignKeyRow>(`
|
||||
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<RoleRow>("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<RowCountRow>(`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<RowCountRow>(`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<RowCountRow>(`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<RowCountRow>('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<RowCountRow>('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<RowCountRow>('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<RehomeAuditRow>(
|
||||
`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);
|
||||
});
|
||||
});
|
||||
55
tests/unit/rehome-tenant.test.ts
Normal file
55
tests/unit/rehome-tenant.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
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('defaults to read-only dry-run and accepts the published CLI aliases', () => {
|
||||
expect(parseTenantRehomeArgs(['--from', source, '--to', target, '--json'])).toEqual({
|
||||
mode: 'dry-run', source, target, json: true,
|
||||
});
|
||||
expect(parseTenantRehomeArgs(['--dry-run', '--source', source, '--target', target]).mode).toBe('dry-run');
|
||||
});
|
||||
|
||||
it('requires backup evidence for every apply invocation', () => {
|
||||
expect(() => parseTenantRehomeArgs(['--apply', '--from', source, '--to', target, '--actor', actor]))
|
||||
.toThrow('--backup-evidence is required');
|
||||
expect(() => parseTenantRehomeArgs(['--from', source, '--source', source, '--to', target]))
|
||||
.toThrow('Duplicate option for source.');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue