krona-staff/tests/integration/rehome-tenant.test.ts
2026-09-29 12:21:57 +07:00

548 lines
21 KiB
TypeScript

import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import pg from 'pg';
import type { QueryResultRow } from 'pg';
import { ulid } from 'ulid';
import { rehomeTenant } from '../../scripts/rehome-tenant.mjs';
const connectionString = process.env.DATABASE_REHOME_TEST_URL;
const describeDatabase = connectionString ? describe : describe.skip;
const confirmation = 'I_ACKNOWLEDGE_DISPOSABLE_PG17_DATABASE';
const evidence = {
backup: {
format: 'pg_dump -Fc',
encrypted: true,
offHost: true,
sha256: 'a'.repeat(64),
createdAt: '2026-09-26T08:00:00Z',
},
restoreDrill: {
status: 'passed',
backupSha256: 'a'.repeat(64),
restoredAt: '2026-09-26T08:30:00Z',
verifiedAt: '2026-09-26T08:45:00Z',
isolatedDatabase: 'krona_staff_restore_check',
},
} as const;
const tenantTables = [
'staff_outbox',
'staff_inbox',
'staff_idempotency_keys',
'staff_audit_log',
'staff_employees',
'staff_attendance_events',
'staff_timesheet_entries',
'staff_org_units',
'staff_org_unit_terms',
'staff_metric_definitions',
'staff_daily_metrics',
'staff_work_items',
'staff_pay_rules',
'staff_pay_assignments',
'staff_work_sessions',
'staff_payroll_runs',
'staff_payroll_lines',
'staff_payouts',
'staff_payroll_ledger',
'staff_standalone_employee_access',
'staff_professions',
'staff_push_subscriptions',
'staff_notification_deliveries',
'staff_sync_devices',
'staff_sync_change_log',
'staff_sync_command_inbox',
'staff_sync_device_checkpoints',
];
interface IdentityRow extends QueryResultRow {
database_name: string;
version_num: number;
is_superuser: boolean;
}
interface CountRow extends QueryResultRow {
count: number;
}
interface RoleRow extends QueryResultRow {
rolsuper: boolean;
rolbypassrls: boolean;
}
interface PresentRow extends QueryResultRow {
present: boolean;
}
interface TableNameRow extends QueryResultRow {
relname: string;
}
interface RowCountRow extends QueryResultRow {
n: string;
}
interface ForeignKeyRow extends QueryResultRow {
source_table: string;
conname: string;
definition: string;
convalidated: boolean;
}
interface RehomeAuditRow extends QueryResultRow {
action: string;
actor_id: string;
old_values: { tenantId: string };
new_values: { tenantId: string; auditRowsAdded: number };
}
async function bootstrapDisposableDatabase(client: pg.Client) {
if (process.env.DATABASE_REHOME_TEST_CONFIRM !== confirmation) {
throw new Error(
`Set DATABASE_REHOME_TEST_CONFIRM=${confirmation} only for a disposable test database.`,
);
}
const identity = await client.query<IdentityRow>(`
SELECT current_database() AS database_name,
current_setting('server_version_num')::integer AS version_num,
role.rolsuper AS is_superuser
FROM pg_roles role WHERE role.rolname = current_user
`);
const row = identity.rows[0]!;
if (
!row.database_name.endsWith('_rehome_test') ||
row.version_num < 170000 ||
!row.is_superuser
) {
throw new Error(
'Integration tests require a disposable database ending _rehome_test on PostgreSQL 17 and a superuser connection.',
);
}
const existing = await client.query<CountRow>(`
SELECT count(*)::integer AS count FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p') AND left(c.relname, 6) = 'staff_'
`);
const hasStaffSchema = Number(existing.rows[0]!.count) > 0;
const appRole = await client.query<RoleRow>(
"SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'krona_staff_app'",
);
if (appRole.rowCount === 0) {
await client.query(
'CREATE ROLE krona_staff_app NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS',
);
} else if (appRole.rows[0]!.rolsuper || appRole.rows[0]!.rolbypassrls) {
throw new Error('The disposable database app role must be NOSUPERUSER and NOBYPASSRLS.');
}
if (!hasStaffSchema) {
const migrations = (await readdir(path.resolve('migrations')))
.filter((name) => name.endsWith('.up.sql'))
.sort();
for (const migration of migrations) {
await client.query(await readFile(path.resolve('migrations', migration), 'utf8'));
}
} else {
const checks: Array<{ table: string; present: boolean }> = [];
for (const table of tenantTables) {
const result = await client.query<PresentRow>(
'SELECT to_regclass($1) IS NOT NULL AS present',
[`public.${table}`],
);
checks.push({ table, present: result.rows[0]!.present });
}
const missing = checks.filter((check) => !check.present);
if (missing.length) throw new Error('The disposable database has an incomplete Staff schema.');
}
const tableNames = await client.query<TableNameRow>(`
SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p')
AND left(c.relname, 6) = 'staff_'
`);
for (const { relname } of tableNames.rows) {
const count = await client.query<RowCountRow>(
`SELECT count(*)::text AS n FROM public."${relname.replaceAll('"', '""')}"`,
);
if (BigInt(count.rows[0]!.n) !== 0n) {
throw new Error('Use an empty disposable database for the tenant-rehome integration test.');
}
}
}
async function listForeignKeyDefinitions(client: pg.Client) {
const result = await client.query<ForeignKeyRow>(
`
SELECT source.relname AS source_table, con.conname,
pg_get_constraintdef(con.oid, true) AS definition,
con.convalidated
FROM pg_constraint con
JOIN pg_class source ON source.oid = con.conrelid
JOIN pg_namespace n ON n.oid = source.relnamespace
WHERE n.nspname = 'public' AND source.relname = ANY($1::text[])
AND con.contype = 'f'
ORDER BY source.relname, con.conname
`,
[tenantTables],
);
return result.rows;
}
describeDatabase('tenant rehome on a disposable PostgreSQL 17 database', () => {
const client = new pg.Client({
connectionString: connectionString!,
application_name: 'krona-staff-rehome-integration-test',
});
let safeToCleanup = false;
const source = ulid();
const target = ulid();
const other = ulid();
const actor = ulid();
const ids = Object.fromEntries(
[
'org',
'employee',
'profession',
'metric',
'rule',
'run',
'outbox',
'device',
'attendance',
'timesheet',
'term',
'dailyMetric',
'workItem',
'assignment',
'session',
'line',
'payout',
'ledger',
'access',
'subscription',
'delivery',
'change',
'command',
'checkpoint',
'audit',
'inbox',
'idempotency',
'otherProfession',
].map((key) => [key, ulid()]),
);
beforeAll(async () => {
await client.connect();
await bootstrapDisposableDatabase(client);
safeToCleanup = true;
});
afterAll(async () => {
if (safeToCleanup) {
await client.query('DROP TABLE IF EXISTS public.external_rehome_reference;');
await client.query(
'DROP TRIGGER IF EXISTS rehome_test_unknown_trigger ON public.staff_employees;',
);
await client.query('DROP FUNCTION IF EXISTS public.rehome_test_trigger_fn();');
const tables = tenantTables.map((table) => `public."${table}"`).join(', ');
await client.query(`TRUNCATE TABLE ${tables} CASCADE`);
}
await client.end();
});
it('moves every tenant table atomically, preserves keys and totals, and audits one added row', async () => {
await client.query('BEGIN');
try {
await client.query(
`INSERT INTO staff_org_units (id, tenant_id, code, name) VALUES ($1, $2, 'unit_test', 'Fixture unit')`,
[ids.org, source],
);
await client.query(
`INSERT INTO staff_professions (id, tenant_id, code, name) VALUES ($1, $2, 'role_test', 'Fixture role')`,
[ids.profession, source],
);
await client.query(
`INSERT INTO staff_employees (id, tenant_id, profession_id, primary_org_unit_id, personnel_number, display_name)
VALUES ($1, $2, $3, $4, 'FIXTURE-1', 'Synthetic fixture')`,
[ids.employee, source, ids.profession, ids.org],
);
await client.query(
`INSERT INTO staff_metric_definitions (id, tenant_id, key, label) VALUES ($1, $2, 'fixture_metric', 'Fixture metric')`,
[ids.metric, source],
);
await client.query(
`INSERT INTO staff_pay_rules (id, tenant_id, code, version, name, rule, effective_from, created_by)
VALUES ($1, $2, 'fixture_rule', 1, 'Fixture rule', '{}'::jsonb, '2026-01-01', $3)`,
[ids.rule, source, actor],
);
await client.query(
`INSERT INTO staff_payroll_runs (id, tenant_id, org_unit_id, period_start, period_end, created_by)
VALUES ($1, $2, $3, '2026-01-01', '2026-01-31', $4)`,
[ids.run, source, ids.org, actor],
);
await client.query(
`INSERT INTO staff_outbox
(id, tenant_id, event_type, event_version, subject_type, subject_id, actor_type, actor_id, payload, correlation_id, occurred_at)
VALUES ($1, $2, 'fixture.event', 1, 'staff.Employee', $3, 'user', $4, '{}'::jsonb, $5, now())`,
[ids.outbox, source, ids.employee, actor, ulid()],
);
await client.query(
`INSERT INTO staff_inbox (tenant_id, event_id, event_type, event_version, correlation_id)
VALUES ($1, $2, 'fixture.event', 1, $3)`,
[source, ulid(), ulid()],
);
await client.query(
`INSERT INTO staff_idempotency_keys (tenant_id, key, request_hash, expires_at)
VALUES ($1, 'fixture-key', 'synthetic-hash', now() + interval '1 day')`,
[source],
);
await client.query(
`INSERT INTO staff_audit_log (id, tenant_id, actor_id, action, entity_type, correlation_id)
VALUES ($1, $2, $3, 'fixture.created', 'fixture', $4)`,
[ids.audit, source, actor, ulid()],
);
await client.query(
`INSERT INTO staff_attendance_events
(id, tenant_id, employee_id, event_type, source, source_event_id, occurred_at, actor_id, correlation_id)
VALUES ($1, $2, $3, 'check_in', 'manual_entry', 'fixture-attendance', now(), $4, $5)`,
[ids.attendance, source, ids.employee, actor, ulid()],
);
await client.query(
`INSERT INTO staff_timesheet_entries (id, tenant_id, employee_id, work_date, updated_by)
VALUES ($1, $2, $3, '2026-01-01', $4)`,
[ulid(), source, ids.employee, actor],
);
await client.query(
`INSERT INTO staff_org_unit_terms (id, tenant_id, org_unit_id, effective_from, created_by)
VALUES ($1, $2, $3, '2026-01-01', $4)`,
[ids.term, source, ids.org, actor],
);
await client.query(
`INSERT INTO staff_daily_metrics (id, tenant_id, org_unit_id, business_date, metric_key, value, recorded_by)
VALUES ($1, $2, $3, '2026-01-01', 'fixture_metric', 123, $4)`,
[ids.dailyMetric, source, ids.org, actor],
);
await client.query(
`INSERT INTO staff_work_items (id, tenant_id, employee_id, org_unit_id, business_date, item_kind, created_by)
VALUES ($1, $2, $3, $4, '2026-01-01', 'fixture_item', $5)`,
[ids.workItem, source, ids.employee, ids.org, actor],
);
await client.query(
`INSERT INTO staff_pay_assignments (id, tenant_id, employee_id, pay_rule_id, effective_from, created_by)
VALUES ($1, $2, $3, $4, '2026-01-01', $5)`,
[ids.assignment, source, ids.employee, ids.rule, actor],
);
await client.query(
`INSERT INTO staff_work_sessions (id, tenant_id, employee_id, org_unit_id, business_date)
VALUES ($1, $2, $3, $4, '2026-01-01')`,
[ids.session, source, ids.employee, ids.org],
);
await client.query(
`INSERT INTO staff_payroll_lines (id, tenant_id, run_id, employee_id, org_unit_id, business_date, pay_rule_id, rule_code, amount_minor)
VALUES ($1, $2, $3, $4, $5, '2026-01-01', $6, 'fixture_rule', 12345)`,
[ids.line, source, ids.run, ids.employee, ids.org, ids.rule],
);
await client.query(
`INSERT INTO staff_payouts (id, tenant_id, employee_id, paid_on, amount_minor, created_by)
VALUES ($1, $2, $3, '2026-01-01', 200, $4)`,
[ids.payout, source, ids.employee, actor],
);
await client.query(
`INSERT INTO staff_payroll_ledger
(id, tenant_id, employee_id, business_date, kind, amount_minor, currency_code, ref_type, correlation_id, created_by)
VALUES ($1, $2, $3, '2026-01-01', 'accrual', 12345, 'RUB', 'payroll_run', $4, $5),
($6, $2, $3, '2026-01-01', 'adjustment', 50, 'KZT', 'manual', $4, $5)`,
[ids.ledger, source, ids.employee, ulid(), actor, ulid()],
);
await client.query(
`INSERT INTO staff_standalone_employee_access
(id, tenant_id, employee_id, subject_id, token_hash, token_hint, created_by)
VALUES ($1, $2, $3, $4, $5, 'synth', $6)`,
[ids.access, source, ids.employee, ulid(), 'a'.repeat(64), actor],
);
await client.query(
`INSERT INTO staff_push_subscriptions (id, tenant_id, employee_id, endpoint, p256dh, auth)
VALUES ($1, $2, $3, 'https://push.invalid/fixture', 'synthetic-key', 'synthetic-auth')`,
[ids.subscription, source, ids.employee],
);
await client.query(
`INSERT INTO staff_notification_deliveries (id, tenant_id, employee_id, outbox_id, endpoint, status)
VALUES ($1, $2, $3, $4, 'https://push.invalid/fixture', 'delivered')`,
[ids.delivery, source, ids.employee, ids.outbox],
);
await client.query(
`INSERT INTO staff_sync_devices (id, tenant_id, device_code, name, token_hash, token_hint, created_by, org_unit_id)
VALUES ($1, $2, 'fixture-device', 'Synthetic device', $3, 'fixture', $4, $5)`,
[ids.device, source, 'b'.repeat(64), actor, ids.org],
);
await client.query(
`INSERT INTO staff_sync_change_log (id, tenant_id, entity_type, entity_id, operation)
VALUES ($1, $2, 'employee', $3, 'upsert')`,
[ids.change, source, ids.employee],
);
await client.query(
`INSERT INTO staff_sync_command_inbox
(id, tenant_id, device_id, command_type, idempotency_key, correlation_id, status, created_at)
VALUES ($1, $2, $3, 'fixture', 'fixture-command', $4, 'applied', now())`,
[ids.command, source, ids.device, ulid()],
);
await client.query(
`INSERT INTO staff_sync_device_checkpoints (tenant_id, device_id, last_acked_cursor_seq)
VALUES ($1, $2, 10)`,
[source, ids.device],
);
await client.query(
`INSERT INTO staff_professions (id, tenant_id, code, name) VALUES ($1, $2, 'other_fixture', 'Other synthetic role')`,
[ids.otherProfession, other],
);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
const oldRole = await client.query<RoleRow>(
"SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'krona_staff_app'",
);
expect(oldRole.rows[0]).toEqual({ rolsuper: false, rolbypassrls: false });
await client.query('SET ROLE krona_staff_app');
try {
await expect(
rehomeTenant(client, { mode: 'apply', source, target, actor, backupEvidence: evidence }),
).rejects.toThrow('privileged database administrator');
} finally {
await client.query('RESET ROLE');
}
const initialForeignKeys = await listForeignKeyDefinitions(client);
const dryRun = await rehomeTenant(client, { mode: 'dry-run', source, target });
expect(Object.keys(dryRun.tables)).toHaveLength(tenantTables.length);
for (const table of tenantTables) {
expect(dryRun.tables[table]).toBe(table === 'staff_payroll_ledger' ? '2' : '1');
}
const occupiedTarget = ulid();
await client.query(
`INSERT INTO staff_professions (id, tenant_id, code, name) VALUES ($1, $2, 'occupied_fixture', 'Occupied synthetic target')`,
[ulid(), occupiedTarget],
);
await expect(
rehomeTenant(client, {
mode: 'apply',
source,
target: occupiedTarget,
actor,
backupEvidence: evidence,
}),
).rejects.toThrow('Target tenant already contains staff data');
await client.query(`
CREATE FUNCTION public.rehome_test_trigger_fn() RETURNS trigger
LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$;
CREATE TRIGGER rehome_test_unknown_trigger BEFORE UPDATE ON public.staff_employees
FOR EACH ROW EXECUTE FUNCTION public.rehome_test_trigger_fn();
`);
await expect(
rehomeTenant(client, { mode: 'apply', source, target, actor, backupEvidence: evidence }),
).rejects.toThrow('Unrecognized user-defined triggers');
await client.query(
'DROP TRIGGER rehome_test_unknown_trigger ON public.staff_employees; DROP FUNCTION public.rehome_test_trigger_fn();',
);
await client.query(
'CREATE TABLE public.external_rehome_reference (employee_id VARCHAR(26) REFERENCES public.staff_employees(id));',
);
await expect(
rehomeTenant(client, { mode: 'apply', source, target, actor, backupEvidence: evidence }),
).rejects.toThrow('foreign key crosses the staff tenant-table boundary');
await client.query('DROP TABLE public.external_rehome_reference;');
await expect(
rehomeTenant(client, {
mode: 'apply',
source,
target,
actor,
backupEvidence: evidence,
correlationId: 'invalid',
}),
).rejects.toThrow('Correlation ID must be a valid ULID.');
for (const table of tenantTables) {
const sourceRows = await client.query<RowCountRow>(
`SELECT count(*)::text AS n FROM public."${table}" WHERE tenant_id = $1`,
[source],
);
expect(sourceRows.rows[0]!.n, `${table} survives rollback`).toBe(
table === 'staff_payroll_ledger' ? '2' : '1',
);
}
expect(await listForeignKeyDefinitions(client)).toEqual(initialForeignKeys);
const result = await rehomeTenant(client, {
mode: 'apply',
source,
target,
actor,
backupEvidence: evidence,
});
expect(result.auditRowsAdded).toBe(1);
expect(result.tables.staff_audit_log).toBe('2');
expect(result.payrollLedger).toEqual([
{ currency: 'KZT', amountMinor: '50' },
{ currency: 'RUB', amountMinor: '12345' },
]);
for (const table of tenantTables) {
const sourceRows = await client.query<RowCountRow>(
`SELECT count(*)::text AS n FROM public."${table}" WHERE tenant_id = $1`,
[source],
);
expect(sourceRows.rows[0]!.n, `${table} source rows`).toBe('0');
const targetRows = await client.query<RowCountRow>(
`SELECT count(*)::text AS n FROM public."${table}" WHERE tenant_id = $1`,
[target],
);
expect(targetRows.rows[0]!.n, `${table} target rows`).toBe(result.tables[table]);
}
const untouchedOther = await client.query<RowCountRow>(
'SELECT count(*)::text AS n FROM staff_professions WHERE tenant_id = $1',
[other],
);
expect(untouchedOther.rows[0]!.n).toBe('1');
expect(await listForeignKeyDefinitions(client)).toEqual(initialForeignKeys);
await client.query('BEGIN');
try {
await client.query('SET LOCAL ROLE krona_staff_app');
await client.query(
"SELECT set_config('app.tenant_id', $1, true), set_config('app.user_id', $2, true)",
[target, actor],
);
const visibleToTarget = await client.query<RowCountRow>(
'SELECT count(*)::text AS n FROM staff_employees',
);
expect(visibleToTarget.rows[0]!.n).toBe('1');
await client.query("SELECT set_config('app.tenant_id', $1, true)", [source]);
const visibleToOldTenant = await client.query<RowCountRow>(
'SELECT count(*)::text AS n FROM staff_employees',
);
expect(visibleToOldTenant.rows[0]!.n).toBe('0');
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK').catch(() => undefined);
throw error;
}
const rehomeAudit = await client.query<RehomeAuditRow>(
`SELECT action, actor_id, old_values, new_values
FROM staff_audit_log WHERE tenant_id = $1 AND action = 'tenant.rehomed'`,
[target],
);
expect(rehomeAudit.rows).toHaveLength(1);
expect(rehomeAudit.rows[0]!.actor_id).toBe(actor);
expect(rehomeAudit.rows[0]!.old_values.tenantId).toBe(source);
expect(rehomeAudit.rows[0]!.new_values.tenantId).toBe(target);
expect(rehomeAudit.rows[0]!.new_values.auditRowsAdded).toBe(1);
});
});