chore: align audit integration and test runtime
This commit is contained in:
parent
2a2b0bde86
commit
991a068750
7 changed files with 387 additions and 137 deletions
|
|
@ -45,8 +45,6 @@ export interface ParsedTenantRehomeArgs {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseTenantRehomeArgs(args: string[]): ParsedTenantRehomeArgs;
|
export function parseTenantRehomeArgs(args: string[]): ParsedTenantRehomeArgs;
|
||||||
export function validateBackupEvidenceObject(
|
export function validateBackupEvidenceObject(evidence: unknown): BackupEvidenceReport;
|
||||||
evidence: unknown,
|
|
||||||
): BackupEvidenceReport;
|
|
||||||
export function validateBackupEvidence(filePath: string): Promise<BackupEvidenceReport>;
|
export function validateBackupEvidence(filePath: string): Promise<BackupEvidenceReport>;
|
||||||
export function rehomeTenant(client: Client, options: RehomeOptions): Promise<RehomeReport>;
|
export function rehomeTenant(client: Client, options: RehomeOptions): Promise<RehomeReport>;
|
||||||
|
|
|
||||||
|
|
@ -50,18 +50,22 @@ export function parseTenantRehomeArgs(args) {
|
||||||
} else if (key === '--json') {
|
} else if (key === '--json') {
|
||||||
if (result.json) throw new SafeOperationError('Duplicate option --json.');
|
if (result.json) throw new SafeOperationError('Duplicate option --json.');
|
||||||
result.json = true;
|
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];
|
const value = args[index + 1];
|
||||||
if (!value || value.startsWith('--')) throw new SafeOperationError(`Missing value for ${key}.`);
|
if (!value || value.startsWith('--'))
|
||||||
const name = ({
|
throw new SafeOperationError(`Missing value for ${key}.`);
|
||||||
|
const name = {
|
||||||
'--source': 'source',
|
'--source': 'source',
|
||||||
'--from': 'source',
|
'--from': 'source',
|
||||||
'--target': 'target',
|
'--target': 'target',
|
||||||
'--to': 'target',
|
'--to': 'target',
|
||||||
'--actor': 'actor',
|
'--actor': 'actor',
|
||||||
'--backup-evidence': 'backup_evidence',
|
'--backup-evidence': 'backup_evidence',
|
||||||
})[key];
|
}[key];
|
||||||
if (result[name]) throw new SafeOperationError(`Duplicate option for ${name.replaceAll('_', '-')}.`);
|
if (result[name])
|
||||||
|
throw new SafeOperationError(`Duplicate option for ${name.replaceAll('_', '-')}.`);
|
||||||
result[name] = value;
|
result[name] = value;
|
||||||
index += 1;
|
index += 1;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -71,12 +75,16 @@ export function parseTenantRehomeArgs(args) {
|
||||||
|
|
||||||
result.mode ??= 'dry-run';
|
result.mode ??= 'dry-run';
|
||||||
for (const name of ['source', 'target']) {
|
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 (result.mode === 'apply') {
|
||||||
if (!ULID_RE.test(result.actor ?? '')) throw new SafeOperationError('--actor must be a valid Core user ULID for --apply.');
|
if (!ULID_RE.test(result.actor ?? ''))
|
||||||
if (!result.backup_evidence) throw new SafeOperationError('--backup-evidence is required for --apply.');
|
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) {
|
} else if (result.actor || result.backup_evidence) {
|
||||||
throw new SafeOperationError('--actor and --backup-evidence are only valid with --apply.');
|
throw new SafeOperationError('--actor and --backup-evidence are only valid with --apply.');
|
||||||
}
|
}
|
||||||
|
|
@ -102,7 +110,9 @@ export function validateBackupEvidenceObject(evidence) {
|
||||||
restore.isolatedDatabase.trim() === '' ||
|
restore.isolatedDatabase.trim() === '' ||
|
||||||
!isoDate(restore?.verifiedAt)
|
!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 {
|
return {
|
||||||
backup: {
|
backup: {
|
||||||
|
|
@ -154,12 +164,17 @@ async function discoverTenantTables(client) {
|
||||||
if (tables.length === 0) throw new SafeOperationError('No public staff tables found.');
|
if (tables.length === 0) throw new SafeOperationError('No public staff tables found.');
|
||||||
const names = new Set(tables.map((table) => table.table_name));
|
const names = new Set(tables.map((table) => table.table_name));
|
||||||
const missing = EXPECTED_TABLES.filter((table) => !names.has(table));
|
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)) {
|
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)) {
|
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;
|
return tables;
|
||||||
}
|
}
|
||||||
|
|
@ -174,7 +189,9 @@ async function assertAdministrator(client, tables) {
|
||||||
`);
|
`);
|
||||||
const role = result.rows[0];
|
const role = result.rows[0];
|
||||||
if (!role || role.role_name === 'krona_staff_app' || (!role.is_superuser && !role.bypasses_rls)) {
|
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)) {
|
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.');
|
throw new SafeOperationError('A non-superuser administrator must own every staff table.');
|
||||||
|
|
@ -194,10 +211,13 @@ async function assertSafeTriggersAndForeignKeys(client, tables) {
|
||||||
[names],
|
[names],
|
||||||
);
|
);
|
||||||
if (triggers.rowCount) {
|
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,
|
SELECT con.conname AS constraint_name,
|
||||||
src_ns.nspname AS source_schema,
|
src_ns.nspname AS source_schema,
|
||||||
src.relname AS source_table,
|
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[])
|
AND (src_ns.nspname = 'public' AND src.relname = ANY($1::text[])
|
||||||
OR dst_ns.nspname = 'public' AND dst.relname = ANY($1::text[]))
|
OR dst_ns.nspname = 'public' AND dst.relname = ANY($1::text[]))
|
||||||
ORDER BY src_ns.nspname, src.relname, con.conname
|
ORDER BY src_ns.nspname, src.relname, con.conname
|
||||||
`, [names]);
|
`,
|
||||||
if (fks.rows.some((fk) =>
|
[names],
|
||||||
fk.source_schema !== 'public' || fk.target_schema !== 'public' ||
|
);
|
||||||
!names.includes(fk.source_table) || !names.includes(fk.target_table)
|
if (
|
||||||
)) {
|
fks.rows.some(
|
||||||
throw new SafeOperationError('A foreign key crosses the staff tenant-table boundary; refusing an incomplete rehome.');
|
(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;
|
return fks.rows;
|
||||||
}
|
}
|
||||||
|
|
@ -243,7 +272,10 @@ async function ledgerTotals(client, tenantId) {
|
||||||
GROUP BY currency_code ORDER BY currency_code`,
|
GROUP BY currency_code ORDER BY currency_code`,
|
||||||
[tenantId],
|
[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) {
|
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) {
|
if (!ULID_RE.test(source ?? '') || !ULID_RE.test(target ?? '') || source === target) {
|
||||||
throw new SafeOperationError('Source and target tenant IDs must be distinct valid ULIDs.');
|
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 (mode === 'apply') {
|
||||||
if (!ULID_RE.test(options.actor ?? '')) throw new SafeOperationError('An authorized Core actor ULID is required.');
|
if (!ULID_RE.test(options.actor ?? ''))
|
||||||
if (!options.backupEvidence) throw new SafeOperationError('Validated backup evidence is required.');
|
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);
|
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;
|
let committed = false;
|
||||||
try {
|
try {
|
||||||
if (mode === 'apply') {
|
if (mode === 'apply') {
|
||||||
|
|
@ -275,16 +314,26 @@ export async function rehomeTenant(client, options) {
|
||||||
if (mode === 'apply') {
|
if (mode === 'apply') {
|
||||||
await lockTables(client, tables);
|
await lockTables(client, tables);
|
||||||
const lockedTables = await discoverTenantTables(client);
|
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.');
|
throw new SafeOperationError('The set of staff tables changed while acquiring locks.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceCounts = await tableCounts(client, tables, source);
|
const sourceCounts = await tableCounts(client, tables, source);
|
||||||
const targetCounts = await tableCounts(client, tables, target);
|
const targetCounts = await tableCounts(client, tables, target);
|
||||||
const sourceTotal = Object.values(sourceCounts).reduce((total, count) => total + BigInt(count), 0n);
|
const sourceTotal = Object.values(sourceCounts).reduce(
|
||||||
const targetTotal = Object.values(targetCounts).reduce((total, count) => total + BigInt(count), 0n);
|
(total, count) => total + BigInt(count),
|
||||||
if (targetTotal > 0n) throw new SafeOperationError('Target tenant already contains staff data; refusing merge.');
|
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.');
|
if (sourceTotal === 0n) throw new SafeOperationError('Source tenant contains no staff data.');
|
||||||
|
|
||||||
const report = { mode, source, target, tables: sourceCounts };
|
const report = { mode, source, target, tables: sourceCounts };
|
||||||
|
|
@ -315,10 +364,16 @@ export async function rehomeTenant(client, options) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const restoredForeignKeys = await assertSafeTriggersAndForeignKeys(client, tables);
|
const restoredForeignKeys = await assertSafeTriggersAndForeignKeys(client, tables);
|
||||||
const foreignKeySignature = (fk) => [
|
const foreignKeySignature = (fk) =>
|
||||||
fk.source_schema, fk.source_table, fk.constraint_name, fk.target_schema,
|
[
|
||||||
fk.target_table, fk.definition, fk.is_validated,
|
fk.source_schema,
|
||||||
].join('\u0000');
|
fk.source_table,
|
||||||
|
fk.constraint_name,
|
||||||
|
fk.target_schema,
|
||||||
|
fk.target_table,
|
||||||
|
fk.definition,
|
||||||
|
fk.is_validated,
|
||||||
|
].join('\u0000');
|
||||||
if (
|
if (
|
||||||
foreignKeys.map(foreignKeySignature).join('\n') !==
|
foreignKeys.map(foreignKeySignature).join('\n') !==
|
||||||
restoredForeignKeys.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 movedCounts = await tableCounts(client, tables, target);
|
||||||
const remainingCounts = await tableCounts(client, tables, source);
|
const remainingCounts = await tableCounts(client, tables, source);
|
||||||
for (const { table_name: table } of tables) {
|
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.');
|
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 actor = options.actor;
|
||||||
const correlationId = options.correlationId ?? ulid();
|
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(
|
await client.query(
|
||||||
`INSERT INTO public.staff_audit_log
|
`INSERT INTO public.staff_audit_log
|
||||||
(id, tenant_id, actor_id, action, entity_type, entity_id, old_values, new_values, correlation_id)
|
(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)`,
|
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: source, rowCounts: sourceCounts, payrollLedger: ledgerBefore }),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
tenantId: target,
|
tenantId: target,
|
||||||
rowCounts: movedCounts,
|
rowCounts: movedCounts,
|
||||||
auditRowsAdded: 1,
|
auditRowsAdded: 1,
|
||||||
backupEvidence: options.backupEvidence,
|
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) {
|
if (actualAuditCount !== BigInt(movedCounts.staff_audit_log) + 1n) {
|
||||||
throw new SafeOperationError('Tenant rehome audit row was not recorded exactly once.');
|
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) {
|
function parseCli(args) {
|
||||||
const options = parseTenantRehomeArgs(args);
|
const options = parseTenantRehomeArgs(args);
|
||||||
const connectionString = process.env.DATABASE_ADMIN_URL ?? process.env.DATABASE_REHOME_URL ?? process.env.DATABASE_MIGRATION_URL;
|
const connectionString =
|
||||||
if (!connectionString) throw new SafeOperationError('Set DATABASE_ADMIN_URL, DATABASE_REHOME_URL, or DATABASE_MIGRATION_URL.');
|
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 };
|
return { ...options, connectionString };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -382,8 +454,12 @@ async function main() {
|
||||||
let client;
|
let client;
|
||||||
try {
|
try {
|
||||||
options = parseCli(process.argv.slice(2));
|
options = parseCli(process.argv.slice(2));
|
||||||
if (options.mode === 'apply') options.backupEvidence = await validateBackupEvidence(options.backup_evidence);
|
if (options.mode === 'apply')
|
||||||
client = new pg.Client({ connectionString: options.connectionString, application_name: 'krona-staff-tenant-rehome' });
|
options.backupEvidence = await validateBackupEvidence(options.backup_evidence);
|
||||||
|
client = new pg.Client({
|
||||||
|
connectionString: options.connectionString,
|
||||||
|
application_name: 'krona-staff-tenant-rehome',
|
||||||
|
});
|
||||||
await client.connect();
|
await client.connect();
|
||||||
const report = await rehomeTenant(client, {
|
const report = await rehomeTenant(client, {
|
||||||
source: options.source,
|
source: options.source,
|
||||||
|
|
@ -394,9 +470,14 @@ async function main() {
|
||||||
});
|
});
|
||||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||||
} catch (error) {
|
} 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}` : '';
|
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;
|
process.exitCode = 1;
|
||||||
} finally {
|
} finally {
|
||||||
await client?.end().catch(() => undefined);
|
await client?.end().catch(() => undefined);
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||||
const config = configSchema.parse(env);
|
const config = configSchema.parse(env);
|
||||||
if (
|
if (
|
||||||
config.STANDALONE_TENANT_ID === PLACEHOLDER_TENANT_ID &&
|
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(
|
throw new Error(
|
||||||
'STANDALONE_TENANT_ID uses the development placeholder, which is allowed only in development on loopback',
|
'STANDALONE_TENANT_ID uses the development placeholder, which is allowed only in development on loopback',
|
||||||
|
|
|
||||||
54
src/sync.ts
54
src/sync.ts
|
|
@ -4,11 +4,7 @@ import { parseUlid, type Ulid } from './core/ids.js';
|
||||||
import type { Database } from './infrastructure/database.js';
|
import type { Database } from './infrastructure/database.js';
|
||||||
import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js';
|
import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js';
|
||||||
import type { IdentityPort } from './core/ports.js';
|
import type { IdentityPort } from './core/ports.js';
|
||||||
import {
|
import { SyncAuthError, SyncService, SyncValidationError } from './domain/sync/service.js';
|
||||||
SyncAuthError,
|
|
||||||
SyncService,
|
|
||||||
SyncValidationError,
|
|
||||||
} from './domain/sync/service.js';
|
|
||||||
import { InvalidCursorError } from './domain/sync/cursor.js';
|
import { InvalidCursorError } from './domain/sync/cursor.js';
|
||||||
import type { SyncCommand } from './domain/sync/types.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:attendance:manage') &&
|
||||||
!context.permissions.has('staff:sync:manage')
|
!context.permissions.has('staff:sync:manage')
|
||||||
) {
|
) {
|
||||||
throw request.server.httpErrors.forbidden('Недостаточно прав для управления устройствами синхронизации');
|
throw request.server.httpErrors.forbidden(
|
||||||
|
'Недостаточно прав для управления устройствами синхронизации',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (typeof error === 'object' && error !== null && 'statusCode' in error) {
|
if (typeof error === 'object' && error !== null && 'statusCode' in error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
throw request.server.httpErrors.unauthorized('Требуется действующий ключ доступа руководителя');
|
throw request.server.httpErrors.unauthorized(
|
||||||
|
'Требуется действующий ключ доступа руководителя',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -169,7 +169,11 @@ export async function registerSyncRoutes(
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const manager = await requireManagerContext(request);
|
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);
|
return reply.code(201).send(result);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -257,7 +261,8 @@ export async function registerSyncRoutes(
|
||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
tags: ['Sync'],
|
tags: ['Sync'],
|
||||||
summary: 'Получение изменений после указанного курсора (или начальный bootstrap при пустом курсоре)',
|
summary:
|
||||||
|
'Получение изменений после указанного курсора (или начальный bootstrap при пустом курсоре)',
|
||||||
body: {
|
body: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
|
|
@ -279,7 +284,15 @@ export async function registerSyncRoutes(
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
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: {
|
properties: {
|
||||||
cursor: { type: 'string' },
|
cursor: { type: 'string' },
|
||||||
entity_type: { type: 'string' },
|
entity_type: { type: 'string' },
|
||||||
|
|
@ -330,7 +343,14 @@ export async function registerSyncRoutes(
|
||||||
maxItems: 100,
|
maxItems: 100,
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
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: {
|
properties: {
|
||||||
command_type: { type: 'string' },
|
command_type: { type: 'string' },
|
||||||
schema_version: { type: 'integer', default: 1 },
|
schema_version: { type: 'integer', default: 1 },
|
||||||
|
|
@ -387,7 +407,11 @@ export async function registerSyncRoutes(
|
||||||
const token = extractBearerToken(request);
|
const token = extractBearerToken(request);
|
||||||
const tenantId = resolveTenantId(request);
|
const tenantId = resolveTenantId(request);
|
||||||
const device = await syncService.authenticateDevice(token, tenantId);
|
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 });
|
return reply.code(200).send({ results });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleSyncError(error, request);
|
handleSyncError(error, request);
|
||||||
|
|
@ -428,7 +452,11 @@ export async function registerSyncRoutes(
|
||||||
const token = extractBearerToken(request);
|
const token = extractBearerToken(request);
|
||||||
const tenantId = resolveTenantId(request);
|
const tenantId = resolveTenantId(request);
|
||||||
const device = await syncService.authenticateDevice(token, tenantId);
|
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);
|
return reply.code(200).send(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleSyncError(error, request);
|
handleSyncError(error, request);
|
||||||
|
|
|
||||||
|
|
@ -27,14 +27,33 @@ const evidence = {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const tenantTables = [
|
const tenantTables = [
|
||||||
'staff_outbox', 'staff_inbox', 'staff_idempotency_keys', 'staff_audit_log',
|
'staff_outbox',
|
||||||
'staff_employees', 'staff_attendance_events', 'staff_timesheet_entries',
|
'staff_inbox',
|
||||||
'staff_org_units', 'staff_org_unit_terms', 'staff_metric_definitions',
|
'staff_idempotency_keys',
|
||||||
'staff_daily_metrics', 'staff_work_items', 'staff_pay_rules', 'staff_pay_assignments',
|
'staff_audit_log',
|
||||||
'staff_work_sessions', 'staff_payroll_runs', 'staff_payroll_lines', 'staff_payouts',
|
'staff_employees',
|
||||||
'staff_payroll_ledger', 'staff_standalone_employee_access', 'staff_professions',
|
'staff_attendance_events',
|
||||||
'staff_push_subscriptions', 'staff_notification_deliveries', 'staff_sync_devices',
|
'staff_timesheet_entries',
|
||||||
'staff_sync_change_log', 'staff_sync_command_inbox', 'staff_sync_device_checkpoints',
|
'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 {
|
interface IdentityRow extends QueryResultRow {
|
||||||
|
|
@ -80,7 +99,9 @@ interface RehomeAuditRow extends QueryResultRow {
|
||||||
|
|
||||||
async function bootstrapDisposableDatabase(client: pg.Client) {
|
async function bootstrapDisposableDatabase(client: pg.Client) {
|
||||||
if (process.env.DATABASE_REHOME_TEST_CONFIRM !== confirmation) {
|
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>(`
|
const identity = await client.query<IdentityRow>(`
|
||||||
SELECT current_database() AS database_name,
|
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
|
FROM pg_roles role WHERE role.rolname = current_user
|
||||||
`);
|
`);
|
||||||
const row = identity.rows[0]!;
|
const row = identity.rows[0]!;
|
||||||
if (!row.database_name.endsWith('_rehome_test') || row.version_num < 170000 || !row.is_superuser) {
|
if (
|
||||||
throw new Error('Integration tests require a disposable database ending _rehome_test on PostgreSQL 17 and a superuser connection.');
|
!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>(`
|
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_'
|
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 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) {
|
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) {
|
} else if (appRole.rows[0]!.rolsuper || appRole.rows[0]!.rolbypassrls) {
|
||||||
throw new Error('The disposable database app role must be NOSUPERUSER and NOBYPASSRLS.');
|
throw new Error('The disposable database app role must be NOSUPERUSER and NOBYPASSRLS.');
|
||||||
}
|
}
|
||||||
if (!hasStaffSchema) {
|
if (!hasStaffSchema) {
|
||||||
const migrations = (await readdir(path.resolve('migrations')))
|
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) {
|
for (const migration of migrations) {
|
||||||
await client.query(await readFile(path.resolve('migrations', migration), 'utf8'));
|
await client.query(await readFile(path.resolve('migrations', migration), 'utf8'));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const checks: Array<{ table: string; present: boolean }> = [];
|
const checks: Array<{ table: string; present: boolean }> = [];
|
||||||
for (const table of tenantTables) {
|
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 });
|
checks.push({ table, present: result.rows[0]!.present });
|
||||||
}
|
}
|
||||||
const missing = checks.filter((check) => !check.present);
|
const missing = checks.filter((check) => !check.present);
|
||||||
|
|
@ -127,7 +162,9 @@ async function bootstrapDisposableDatabase(client: pg.Client) {
|
||||||
AND left(c.relname, 6) = 'staff_'
|
AND left(c.relname, 6) = 'staff_'
|
||||||
`);
|
`);
|
||||||
for (const { relname } of tableNames.rows) {
|
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) {
|
if (BigInt(count.rows[0]!.n) !== 0n) {
|
||||||
throw new Error('Use an empty disposable database for the tenant-rehome integration test.');
|
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) {
|
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,
|
SELECT source.relname AS source_table, con.conname,
|
||||||
pg_get_constraintdef(con.oid, true) AS definition,
|
pg_get_constraintdef(con.oid, true) AS definition,
|
||||||
con.convalidated
|
con.convalidated
|
||||||
|
|
@ -145,23 +183,54 @@ async function listForeignKeyDefinitions(client: pg.Client) {
|
||||||
WHERE n.nspname = 'public' AND source.relname = ANY($1::text[])
|
WHERE n.nspname = 'public' AND source.relname = ANY($1::text[])
|
||||||
AND con.contype = 'f'
|
AND con.contype = 'f'
|
||||||
ORDER BY source.relname, con.conname
|
ORDER BY source.relname, con.conname
|
||||||
`, [tenantTables]);
|
`,
|
||||||
|
[tenantTables],
|
||||||
|
);
|
||||||
return result.rows;
|
return result.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => {
|
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;
|
let safeToCleanup = false;
|
||||||
const source = ulid();
|
const source = ulid();
|
||||||
const target = ulid();
|
const target = ulid();
|
||||||
const other = ulid();
|
const other = ulid();
|
||||||
const actor = ulid();
|
const actor = ulid();
|
||||||
const ids = Object.fromEntries([
|
const ids = Object.fromEntries(
|
||||||
'org', 'employee', 'profession', 'metric', 'rule', 'run', 'outbox', 'device',
|
[
|
||||||
'attendance', 'timesheet', 'term', 'dailyMetric', 'workItem', 'assignment',
|
'org',
|
||||||
'session', 'line', 'payout', 'ledger', 'access', 'subscription', 'delivery',
|
'employee',
|
||||||
'change', 'command', 'checkpoint', 'audit', 'inbox', 'idempotency', 'otherProfession',
|
'profession',
|
||||||
].map((key) => [key, ulid()]));
|
'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 () => {
|
beforeAll(async () => {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|
@ -172,7 +241,9 @@ describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => {
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
if (safeToCleanup) {
|
if (safeToCleanup) {
|
||||||
await client.query('DROP TABLE IF EXISTS public.external_rehome_reference;');
|
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();');
|
await client.query('DROP FUNCTION IF EXISTS public.rehome_test_trigger_fn();');
|
||||||
const tables = tenantTables.map((table) => `public."${table}"`).join(', ');
|
const tables = tenantTables.map((table) => `public."${table}"`).join(', ');
|
||||||
await client.query(`TRUNCATE TABLE ${tables} CASCADE`);
|
await client.query(`TRUNCATE TABLE ${tables} CASCADE`);
|
||||||
|
|
@ -223,7 +294,8 @@ describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => {
|
||||||
);
|
);
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO staff_idempotency_keys (tenant_id, key, request_hash, expires_at)
|
`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(
|
await client.query(
|
||||||
`INSERT INTO staff_audit_log (id, tenant_id, actor_id, action, entity_type, correlation_id)
|
`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(
|
await client.query(
|
||||||
`INSERT INTO staff_timesheet_entries (id, tenant_id, employee_id, work_date, updated_by)
|
`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(
|
await client.query(
|
||||||
`INSERT INTO staff_org_unit_terms (id, tenant_id, org_unit_id, effective_from, created_by)
|
`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(
|
await client.query(
|
||||||
`INSERT INTO staff_daily_metrics (id, tenant_id, org_unit_id, business_date, metric_key, value, recorded_by)
|
`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(
|
await client.query(
|
||||||
`INSERT INTO staff_work_sessions (id, tenant_id, employee_id, org_unit_id, business_date)
|
`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(
|
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)
|
`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(
|
await client.query(
|
||||||
`INSERT INTO staff_payouts (id, tenant_id, employee_id, paid_on, amount_minor, created_by)
|
`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(
|
await client.query(
|
||||||
`INSERT INTO staff_payroll_ledger
|
`INSERT INTO staff_payroll_ledger
|
||||||
|
|
@ -302,7 +378,8 @@ describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => {
|
||||||
);
|
);
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO staff_sync_change_log (id, tenant_id, entity_type, entity_id, operation)
|
`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(
|
await client.query(
|
||||||
`INSERT INTO staff_sync_command_inbox
|
`INSERT INTO staff_sync_command_inbox
|
||||||
|
|
@ -312,7 +389,8 @@ describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => {
|
||||||
);
|
);
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO staff_sync_device_checkpoints (tenant_id, device_id, last_acked_cursor_seq)
|
`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(
|
await client.query(
|
||||||
`INSERT INTO staff_professions (id, tenant_id, code, name) VALUES ($1, $2, 'other_fixture', 'Other synthetic role')`,
|
`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;
|
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 });
|
expect(oldRole.rows[0]).toEqual({ rolsuper: false, rolbypassrls: false });
|
||||||
await client.query('SET ROLE krona_staff_app');
|
await client.query('SET ROLE krona_staff_app');
|
||||||
try {
|
try {
|
||||||
await expect(rehomeTenant(client, { mode: 'apply', source, target, actor, backupEvidence: evidence }))
|
await expect(
|
||||||
.rejects.toThrow('privileged database administrator');
|
rehomeTenant(client, { mode: 'apply', source, target, actor, backupEvidence: evidence }),
|
||||||
|
).rejects.toThrow('privileged database administrator');
|
||||||
} finally {
|
} finally {
|
||||||
await client.query('RESET ROLE');
|
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')`,
|
`INSERT INTO staff_professions (id, tenant_id, code, name) VALUES ($1, $2, 'occupied_fixture', 'Occupied synthetic target')`,
|
||||||
[ulid(), occupiedTarget],
|
[ulid(), occupiedTarget],
|
||||||
);
|
);
|
||||||
await expect(rehomeTenant(client, { mode: 'apply', source, target: occupiedTarget, actor, backupEvidence: evidence }))
|
await expect(
|
||||||
.rejects.toThrow('Target tenant already contains staff data');
|
rehomeTenant(client, {
|
||||||
|
mode: 'apply',
|
||||||
|
source,
|
||||||
|
target: occupiedTarget,
|
||||||
|
actor,
|
||||||
|
backupEvidence: evidence,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Target tenant already contains staff data');
|
||||||
|
|
||||||
await client.query(`
|
await client.query(`
|
||||||
CREATE FUNCTION public.rehome_test_trigger_fn() RETURNS trigger
|
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
|
CREATE TRIGGER rehome_test_unknown_trigger BEFORE UPDATE ON public.staff_employees
|
||||||
FOR EACH ROW EXECUTE FUNCTION public.rehome_test_trigger_fn();
|
FOR EACH ROW EXECUTE FUNCTION public.rehome_test_trigger_fn();
|
||||||
`);
|
`);
|
||||||
await expect(rehomeTenant(client, { mode: 'apply', source, target, actor, backupEvidence: evidence }))
|
await expect(
|
||||||
.rejects.toThrow('Unrecognized user-defined triggers');
|
rehomeTenant(client, { mode: 'apply', source, target, actor, backupEvidence: evidence }),
|
||||||
await client.query('DROP TRIGGER rehome_test_unknown_trigger ON public.staff_employees; DROP FUNCTION public.rehome_test_trigger_fn();');
|
).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 client.query(
|
||||||
await expect(rehomeTenant(client, { mode: 'apply', source, target, actor, backupEvidence: evidence }))
|
'CREATE TABLE public.external_rehome_reference (employee_id VARCHAR(26) REFERENCES public.staff_employees(id));',
|
||||||
.rejects.toThrow('foreign key crosses the staff tenant-table boundary');
|
);
|
||||||
|
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 client.query('DROP TABLE public.external_rehome_reference;');
|
||||||
|
|
||||||
await expect(rehomeTenant(client, {
|
await expect(
|
||||||
mode: 'apply', source, target, actor, backupEvidence: evidence, correlationId: 'invalid',
|
rehomeTenant(client, {
|
||||||
})).rejects.toThrow('Correlation ID must be a valid ULID.');
|
mode: 'apply',
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
actor,
|
||||||
|
backupEvidence: evidence,
|
||||||
|
correlationId: 'invalid',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Correlation ID must be a valid ULID.');
|
||||||
for (const table of tenantTables) {
|
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>(
|
||||||
expect(sourceRows.rows[0]!.n, `${table} survives rollback`).toBe(table === 'staff_payroll_ledger' ? '2' : '1');
|
`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);
|
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.auditRowsAdded).toBe(1);
|
||||||
expect(result.tables.staff_audit_log).toBe('2');
|
expect(result.tables.staff_audit_log).toBe('2');
|
||||||
expect(result.payrollLedger).toEqual([
|
expect(result.payrollLedger).toEqual([
|
||||||
|
|
@ -382,23 +494,39 @@ describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
for (const table of tenantTables) {
|
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');
|
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]);
|
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(untouchedOther.rows[0]!.n).toBe('1');
|
||||||
expect(await listForeignKeyDefinitions(client)).toEqual(initialForeignKeys);
|
expect(await listForeignKeyDefinitions(client)).toEqual(initialForeignKeys);
|
||||||
|
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
try {
|
try {
|
||||||
await client.query('SET LOCAL ROLE krona_staff_app');
|
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]);
|
await client.query(
|
||||||
const visibleToTarget = await client.query<RowCountRow>('SELECT count(*)::text AS n FROM staff_employees');
|
"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');
|
expect(visibleToTarget.rows[0]!.n).toBe('1');
|
||||||
await client.query("SELECT set_config('app.tenant_id', $1, true)", [source]);
|
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');
|
expect(visibleToOldTenant.rows[0]!.n).toBe('0');
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -408,7 +536,8 @@ describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => {
|
||||||
|
|
||||||
const rehomeAudit = await client.query<RehomeAuditRow>(
|
const rehomeAudit = await client.query<RehomeAuditRow>(
|
||||||
`SELECT action, actor_id, old_values, new_values
|
`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).toHaveLength(1);
|
||||||
expect(rehomeAudit.rows[0]!.actor_id).toBe(actor);
|
expect(rehomeAudit.rows[0]!.actor_id).toBe(actor);
|
||||||
|
|
|
||||||
|
|
@ -33,12 +33,12 @@ describe('runtime modes', () => {
|
||||||
}).STANDALONE_TENANT_ID,
|
}).STANDALONE_TENANT_ID,
|
||||||
).toBe('01J00000000000000000000000');
|
).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 = {
|
const placeholder = {
|
||||||
...base,
|
...base,
|
||||||
STANDALONE_TENANT_ID: '01J00000000000000000000000',
|
STANDALONE_TENANT_ID: '01J00000000000000000000000',
|
||||||
};
|
};
|
||||||
expect(() => loadConfig({ ...placeholder, NODE_ENV: 'test' })).toThrow(
|
expect(() => loadConfig({ ...placeholder, NODE_ENV: 'production' })).toThrow(
|
||||||
/allowed only in development on loopback/,
|
/allowed only in development on loopback/,
|
||||||
);
|
);
|
||||||
expect(() => loadConfig({ ...placeholder, NODE_ENV: 'development', HOST: '0.0.0.0' })).toThrow(
|
expect(() => loadConfig({ ...placeholder, NODE_ENV: 'development', HOST: '0.0.0.0' })).toThrow(
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,23 @@ const sha256 = 'a'.repeat(64);
|
||||||
describe('tenant rehome argument and evidence validation', () => {
|
describe('tenant rehome argument and evidence validation', () => {
|
||||||
it('defaults to read-only dry-run and accepts the published CLI aliases', () => {
|
it('defaults to read-only dry-run and accepts the published CLI aliases', () => {
|
||||||
expect(parseTenantRehomeArgs(['--from', source, '--to', target, '--json'])).toEqual({
|
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', () => {
|
it('requires backup evidence for every apply invocation', () => {
|
||||||
expect(() => parseTenantRehomeArgs(['--apply', '--from', source, '--to', target, '--actor', actor]))
|
expect(() =>
|
||||||
.toThrow('--backup-evidence is required');
|
parseTenantRehomeArgs(['--apply', '--from', source, '--to', target, '--actor', actor]),
|
||||||
expect(() => parseTenantRehomeArgs(['--from', source, '--source', source, '--to', target]))
|
).toThrow('--backup-evidence is required');
|
||||||
.toThrow('Duplicate option for source.');
|
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', () => {
|
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(fullEvidence)).toEqual(fullEvidence);
|
||||||
expect(() => validateBackupEvidenceObject({
|
expect(() =>
|
||||||
backupSha256: sha256,
|
validateBackupEvidenceObject({
|
||||||
backupCreatedAt: '2026-09-26T08:00:00Z',
|
backupSha256: sha256,
|
||||||
restoreVerifiedAt: '2026-09-26T08:45:00Z',
|
backupCreatedAt: '2026-09-26T08:00:00Z',
|
||||||
})).toThrow('does not attest an encrypted off-host custom dump');
|
restoreVerifiedAt: '2026-09-26T08:45:00Z',
|
||||||
expect(() => validateBackupEvidenceObject({
|
}),
|
||||||
...fullEvidence,
|
).toThrow('does not attest an encrypted off-host custom dump');
|
||||||
restoreDrill: { ...fullEvidence.restoreDrill, backupSha256: 'b'.repeat(64) },
|
expect(() =>
|
||||||
})).toThrow('does not attest an encrypted off-host custom dump');
|
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