548 lines
21 KiB
TypeScript
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);
|
|
});
|
|
});
|