chore: align audit integration and test runtime

This commit is contained in:
Ochenstarik 2026-09-29 12:21:49 +07:00
parent 2a2b0bde86
commit 991a068750
7 changed files with 387 additions and 137 deletions

View file

@ -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<BackupEvidenceReport>;
export function rehomeTenant(client: Client, options: RehomeOptions): Promise<RehomeReport>;

View file

@ -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);

View file

@ -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',

View file

@ -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);

View file

@ -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<IdentityRow>(`
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<CountRow>(`
@ -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<RoleRow>("SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'krona_staff_app'");
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');
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<PresentRow>('SELECT to_regclass($1) IS NOT NULL AS present', [`public.${table}`]);
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);
@ -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<RowCountRow>(`SELECT count(*)::text AS n FROM public."${relname.replaceAll('"', '""')}"`);
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.');
}
@ -135,7 +172,8 @@ async function bootstrapDisposableDatabase(client: pg.Client) {
}
async function listForeignKeyDefinitions(client: pg.Client) {
const result = await client.query<ForeignKeyRow>(`
const result = await client.query<ForeignKeyRow>(
`
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<RoleRow>("SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'krona_staff_app'");
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');
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<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');
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 });
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<RowCountRow>(`SELECT count(*)::text AS n FROM public."${table}" WHERE tenant_id = $1`, [source]);
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]);
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]);
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');
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');
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) {
@ -408,7 +536,8 @@ describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => {
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],
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);

View file

@ -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(

View file

@ -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');
});
});