krona-staff/tests/e2e/60-pilot-rehome.e2e.ts
grok d7b235248a test(e2e): PILOT-REHOME — приёмка будущего scripts/rehome-tenant.mjs
Почему: TASK-GROK-3 п.5 / TASK-CODEX-2 п.2.2 — данные пилота живут под заглушкой
01J00000000000000000000000, перенос на 01M2R6AC5652NPT3MG34DKN4ZP нельзя проверить руками.
[PILOT-REHOME-SEED] (должен быть зелёным): таблицы с tenant_id берутся из information_schema,
в каждую сеется 2+ строки заглушки и постороннего арендатора; новая таблица без сида → красный.
[PILOT-REHOME]: --dry-run печатает те же числа и ничего не меняет; перенос админом БД; под
заглушкой 0 строк, счётчики и SUM(amount_minor) ledger совпадают, посторонний не тронут, запись
в staff_audit_log нового арендатора, krona_staff_app видит тех же сотрудников под новым tenant и
никого под старым, UPDATE staff_payroll_ledger отклоняется, повторный запуск отказывает.
Пока скрипта нет — красный с понятным сообщением, ID в known-defects.json.
Сценарий проверен временным эталоном (не коммитился): зелёный; мутации без аудита / без отказа
на повтор / с пропущенной таблицей / с записью в dry-run — красные (report-evidence/round3-pilot-rehome.txt).
client.ts: dbQueryAs(tenant, …) — запрос ролью приложения под заданным арендатором.
2026-09-26 16:10:07 +07:00

543 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// PILOT-REHOME (TASK-GROK-3 п.5): приёмочный сценарий для будущего scripts/rehome-tenant.mjs
// (TASK-CODEX-2 п.2.2) — перенос данных пилота с заглушки арендатора на настоящий tenant.
// Предполагаемый контракт (NOTES-FOR-EXECUTORS.md, C6):
// DATABASE_ADMIN_URL=<админ БД> node scripts/rehome-tenant.mjs --from <ulid> --to <ulid> [--dry-run] [--json]
// --json печатает (последней строкой) {"tables":{"<таблица>":<строк>},"ledger_amount_minor_sum":<число>}.
// Таблицы берутся из information_schema.columns (column_name = 'tenant_id'), а не списком вручную:
// новая таблица без сида делает красным [PILOT-REHOME-SEED], чтобы перенос не забыл её.
import { spawnSync } from 'node:child_process';
import { createHash, randomBytes } from 'node:crypto';
import { existsSync } from 'node:fs';
import path from 'node:path';
import pg from 'pg';
import { describe, expect, it } from 'vitest';
import { ctx, dbQueryAs } from './harness/client.js';
import { randomUlid } from './harness/context.js';
import { baseEnv } from '../../scripts/test/node-commands.mjs';
const repoRoot = path.resolve(import.meta.dirname, '../..');
const scriptPath = path.join(repoRoot, 'scripts/rehome-tenant.mjs');
const STUB = '01J00000000000000000000000';
const TARGET = '01M2R6AC5652NPT3MG34DKN4ZP';
const FOREIGN = randomUlid();
type RehomeReport = { tables?: Record<string, number>; ledger_amount_minor_sum?: number | string };
type Snapshot = { counts: Record<string, number>; ledgerSum: string };
async function withMigrator<T>(
tenant: string,
fn: (client: pg.Client) => Promise<T>,
userId?: string,
connectionString = ctx().migrationDatabaseUrl,
): Promise<T> {
const client = new pg.Client({ connectionString });
await client.connect();
try {
await client.query('BEGIN');
await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenant]);
// Политики INSERT журнала аудита, начислений, отметок и табеля требуют app.user_id = автор.
if (userId) await client.query("SELECT set_config('app.user_id', $1, true)", [userId]);
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK').catch(() => undefined);
throw error;
} finally {
await client.end();
}
}
async function tenantTables(): Promise<string[]> {
return withMigrator(STUB, async (client) => {
const result = await client.query<{ table_name: string }>(
`SELECT DISTINCT c.table_name FROM information_schema.columns c
JOIN information_schema.tables t USING (table_schema, table_name)
WHERE c.table_schema = 'public' AND c.column_name = 'tenant_id' AND t.table_type = 'BASE TABLE'
ORDER BY 1`,
);
return result.rows.map((row) => row.table_name);
});
}
async function snapshot(tenant: string, tables: string[]): Promise<Snapshot> {
return withMigrator(tenant, async (client) => {
const counts: Record<string, number> = {};
for (const table of tables) {
const result = await client.query<{ n: number }>(
`SELECT count(*)::int AS n FROM ${table} WHERE tenant_id = $1`,
[tenant],
);
counts[table] = result.rows[0]!.n;
}
const sum = await client.query<{ s: string }>(
'SELECT coalesce(sum(amount_minor), 0)::text AS s FROM staff_payroll_ledger WHERE tenant_id = $1',
[tenant],
);
return { counts, ledgerSum: sum.rows[0]!.s };
});
}
/**
* Удаляет строки арендатора из всех таблиц (дочерние раньше родительских — повторными проходами).
* У staff_payroll_ledger нет политики DELETE, поэтому под FORCE RLS мигратор его не очистит:
* при наличии админа БД чистит админ, иначе остаток ловит проверка ниже.
*/
async function purge(tenant: string, tables: string[]) {
const connection = ctx().adminDatabaseUrl ?? ctx().migrationDatabaseUrl;
await withMigrator(
tenant,
async (client) => {
let pending = [...tables];
for (let pass = 0; pass < tables.length && pending.length > 0; pass += 1) {
const failed: string[] = [];
for (const table of pending) {
await client.query('SAVEPOINT purge');
try {
await client.query(`DELETE FROM ${table} WHERE tenant_id = $1`, [tenant]);
await client.query('RELEASE SAVEPOINT purge');
} catch {
await client.query('ROLLBACK TO SAVEPOINT purge');
failed.push(table);
}
}
pending = failed;
}
if (pending.length) throw new Error(`не удалось очистить ${tenant}: ${pending.join(', ')}`);
},
undefined,
connection,
);
const left = Object.entries((await snapshot(tenant, tables)).counts).filter(([, n]) => n > 0);
if (left.length) {
throw new Error(
`после очистки у ${tenant} остались строки (нужен админ БД: E2E_DB_ADMIN_URL): ${left.map(([t, n]) => `${t}=${n}`).join(', ')}`,
);
}
}
const hex64 = () => randomBytes(32).toString('hex');
const code = (prefix: string) => `${prefix}_${randomBytes(4).toString('hex')}`;
/**
* Синтетические данные арендатора: по 2+ строки в каждой известной таблице с tenant_id.
* Возвращает id сотрудников и список засеянных таблиц. Пишет мигратор (владелец таблиц),
* FORCE RLS действует и на него, поэтому app.tenant_id выставлен в транзакции.
*/
async function seed(tenant: string) {
const ids = (n: number) => Array.from({ length: n }, () => randomUlid());
const actor = randomUlid();
const corr = () => randomUlid();
const seeded = new Set<string>();
const employees = ids(3);
const amounts = [150_000n, 72_300n, -12_300n].map(
(base) => base + BigInt(randomBytes(2).readUInt16BE(0)),
);
await withMigrator(
tenant,
async (client) => {
const q = async (table: string, sql: string, params: unknown[]) => {
await client.query(sql, params);
seeded.add(table);
};
const [ou1, ou2] = ids(2);
for (const [i, ou] of [ou1, ou2].entries()) {
await q(
'staff_org_units',
'INSERT INTO staff_org_units (id, tenant_id, code, name) VALUES ($1,$2,$3,$4)',
[ou, tenant, `rehome-${i}-${randomBytes(3).toString('hex')}`, `Точка ${i + 1}`],
);
}
const professions = ids(2);
for (const [i, id] of professions.entries()) {
await q(
'staff_professions',
'INSERT INTO staff_professions (id, tenant_id, code, name) VALUES ($1,$2,$3,$4)',
[id, tenant, code('prof'), `Профессия ${i}-${randomBytes(3).toString('hex')}`],
);
}
for (const [i, id] of employees.entries()) {
await q(
'staff_employees',
'INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, primary_org_unit_id, profession_id) VALUES ($1,$2,$3,$4,$5,$6)',
[
id,
tenant,
`R-${randomBytes(4).toString('hex')}`,
`Сотрудник ${i + 1}`,
i % 2 ? ou2 : ou1,
professions[i % 2],
],
);
}
const metricKeys = [code('metric'), code('metric')];
for (const key of metricKeys) {
await q(
'staff_metric_definitions',
'INSERT INTO staff_metric_definitions (id, tenant_id, key, label) VALUES ($1,$2,$3,$4)',
[randomUlid(), tenant, key, `Метрика ${key}`],
);
}
for (const [i, key] of metricKeys.entries()) {
await q(
'staff_daily_metrics',
'INSERT INTO staff_daily_metrics (id, tenant_id, org_unit_id, business_date, metric_key, value, recorded_by) VALUES ($1,$2,$3,$4,$5,$6,$7)',
[randomUlid(), tenant, ou1, `2026-09-0${i + 1}`, key, 1000 + i, actor],
);
}
for (const [i, ou] of [ou1, ou2].entries()) {
await q(
'staff_org_unit_terms',
'INSERT INTO staff_org_unit_terms (id, tenant_id, org_unit_id, effective_from, created_by) VALUES ($1,$2,$3,$4,$5)',
[randomUlid(), tenant, ou, `2026-0${i + 1}-01`, actor],
);
}
const rules = ids(2);
for (const [i, id] of rules.entries()) {
await q(
'staff_pay_rules',
'INSERT INTO staff_pay_rules (id, tenant_id, code, version, name, rule, effective_from, created_by) VALUES ($1,$2,$3,1,$4,$5,$6,$7)',
[
id,
tenant,
code('rule'),
`Правило ${i}`,
JSON.stringify({ kind: 'hourly', rate_minor: 12_300 }),
'2026-01-01',
actor,
],
);
}
for (const [i, emp] of employees.slice(0, 2).entries()) {
await q(
'staff_pay_assignments',
'INSERT INTO staff_pay_assignments (id, tenant_id, employee_id, pay_rule_id, effective_from, created_by) VALUES ($1,$2,$3,$4,$5,$6)',
[randomUlid(), tenant, emp, rules[i], '2026-01-01', actor],
);
}
for (const [i, emp] of employees.slice(0, 2).entries()) {
await q(
'staff_attendance_events',
'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,$4,$5,$6,$7,$8,$9)',
[
randomUlid(),
tenant,
emp,
i ? 'check_out' : 'check_in',
'terminal',
`src-${randomBytes(6).toString('hex')}`,
'2026-09-01T09:00:00Z',
actor,
corr(),
],
);
await q(
'staff_work_sessions',
'INSERT INTO staff_work_sessions (id, tenant_id, employee_id, org_unit_id, business_date) VALUES ($1,$2,$3,$4,$5)',
[randomUlid(), tenant, emp, ou1, '2026-09-01'],
);
await q(
'staff_timesheet_entries',
'INSERT INTO staff_timesheet_entries (id, tenant_id, employee_id, work_date, updated_by) VALUES ($1,$2,$3,$4,$5)',
[randomUlid(), tenant, emp, '2026-09-01', actor],
);
await q(
'staff_work_items',
'INSERT INTO staff_work_items (id, tenant_id, employee_id, org_unit_id, business_date, item_kind, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7)',
[randomUlid(), tenant, emp, ou1, '2026-09-01', 'rehome_item', actor],
);
}
const runs = ids(2);
for (const [i, run] of runs.entries()) {
await q(
'staff_payroll_runs',
'INSERT INTO staff_payroll_runs (id, tenant_id, period_start, period_end, created_by) VALUES ($1,$2,$3,$4,$5)',
[run, tenant, `2026-0${i + 7}-01`, `2026-0${i + 7}-28`, actor],
);
await q(
'staff_payroll_lines',
'INSERT INTO staff_payroll_lines (id, tenant_id, run_id, employee_id, org_unit_id, business_date, pay_rule_id, rule_code) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)',
[randomUlid(), tenant, run, employees[i], ou1, `2026-0${i + 7}-02`, rules[i], 'hourly'],
);
await q(
'staff_payouts',
'INSERT INTO staff_payouts (id, tenant_id, employee_id, paid_on, amount_minor, created_by) VALUES ($1,$2,$3,$4,$5,$6)',
[randomUlid(), tenant, employees[i], `2026-0${i + 8}-05`, 50_000 + i, actor],
);
}
for (const [i, amount] of amounts.entries()) {
await q(
'staff_payroll_ledger',
'INSERT INTO staff_payroll_ledger (id, tenant_id, employee_id, business_date, kind, amount_minor, ref_type, correlation_id, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
[
randomUlid(),
tenant,
employees[i % 2],
'2026-09-01',
amount < 0n ? 'adjustment' : 'accrual',
amount.toString(),
'manual',
corr(),
actor,
],
);
}
const outbox = ids(2);
for (const id of outbox) {
await q(
'staff_outbox',
'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,$3,1,$4,$5,$6,$7,$8,$9,now())',
[id, tenant, 'staff.rehome_seed', 'employee', employees[0], 'user', actor, '{}', corr()],
);
}
for (let i = 0; i < 2; i += 1) {
await q(
'staff_inbox',
'INSERT INTO staff_inbox (event_id, tenant_id, event_type, event_version, correlation_id) VALUES ($1,$2,$3,1,$4)',
[randomUlid(), tenant, 'core.rehome_seed', corr()],
);
await q(
'staff_idempotency_keys',
"INSERT INTO staff_idempotency_keys (tenant_id, key, request_hash, expires_at) VALUES ($1,$2,$3, now() + interval '1 day')",
[tenant, `idem-${randomBytes(8).toString('hex')}`, hex64()],
);
const endpoint = `https://push.example.test/${randomBytes(8).toString('hex')}`;
await q(
'staff_push_subscriptions',
'INSERT INTO staff_push_subscriptions (id, tenant_id, employee_id, endpoint, p256dh, auth) VALUES ($1,$2,$3,$4,$5,$6)',
[
randomUlid(),
tenant,
employees[i],
endpoint,
randomBytes(16).toString('base64url'),
randomBytes(8).toString('base64url'),
],
);
await q(
'staff_notification_deliveries',
'INSERT INTO staff_notification_deliveries (id, tenant_id, employee_id, outbox_id, endpoint) VALUES ($1,$2,$3,$4,$5)',
[randomUlid(), tenant, employees[i], outbox[i], endpoint],
);
await q(
'staff_standalone_employee_access',
'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,$6,$7)',
[
randomUlid(),
tenant,
employees[i],
randomUlid(),
createHash('sha256').update(randomBytes(32)).digest('hex'),
'e2e…',
actor,
],
);
await q(
'staff_sync_change_log',
'INSERT INTO staff_sync_change_log (id, tenant_id, entity_type, entity_id, operation) VALUES ($1,$2,$3,$4,$5)',
[randomUlid(), tenant, 'employee', employees[i], 'upsert'],
);
await q(
'staff_audit_log',
'INSERT INTO staff_audit_log (id, tenant_id, actor_id, action, entity_type, entity_id, correlation_id) VALUES ($1,$2,$3,$4,$5,$6,$7)',
[randomUlid(), tenant, actor, 'employee.seeded', 'employee', employees[i], corr()],
);
}
const devices = ids(2);
for (const device of devices) {
await q(
'staff_sync_devices',
'INSERT INTO staff_sync_devices (id, tenant_id, device_code, name, token_hash, token_hint, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7)',
[
device,
tenant,
`dev-${randomBytes(4).toString('hex')}`,
'Терминал',
hex64(),
'kstaff…',
actor,
],
);
await q(
'staff_sync_command_inbox',
'INSERT INTO staff_sync_command_inbox (id, tenant_id, device_id, command_type, idempotency_key, correlation_id, status, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,now())',
[
randomUlid(),
tenant,
device,
'record_attendance',
`idem-${randomBytes(6).toString('hex')}`,
corr(),
'applied',
],
);
await q(
'staff_sync_device_checkpoints',
'INSERT INTO staff_sync_device_checkpoints (device_id, tenant_id) VALUES ($1,$2)',
[device, tenant],
);
}
},
actor,
);
return { employees, seeded };
}
function runRehome(args: string[], adminUrl: string) {
const result = spawnSync(
process.execPath,
[scriptPath, '--from', STUB, '--to', TARGET, ...args],
{
cwd: repoRoot,
env: { ...baseEnv(), DATABASE_ADMIN_URL: adminUrl },
encoding: 'utf8',
timeout: 120_000,
},
);
const lines = (result.stdout ?? '').trim().split('\n');
let report: RehomeReport | null = null;
for (const line of [...lines].reverse()) {
if (!line.trim().startsWith('{')) continue;
try {
report = JSON.parse(line) as RehomeReport;
break;
} catch {
// не JSON — ищем дальше
}
}
return {
status: result.status,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
report,
};
}
const state: {
tables: string[];
employees: string[];
before?: { stub: Snapshot; foreign: Snapshot };
} = { tables: [], employees: [] };
describe('PILOT-REHOME: перенос заглушки арендатора на настоящий tenant', () => {
it('[PILOT-REHOME-SEED] сиды покрывают все таблицы с tenant_id (список из information_schema)', async () => {
state.tables = await tenantTables();
expect(state.tables.length).toBeGreaterThan(0);
await purge(STUB, state.tables);
await purge(TARGET, state.tables);
const stub = await seed(STUB);
const foreign = await seed(FOREIGN);
state.employees = stub.employees;
const unseeded = state.tables.filter((table) => !stub.seeded.has(table));
expect(
unseeded,
`таблицы с tenant_id без сида в tests/e2e/60-pilot-rehome.e2e.ts — перенос арендатора их не проверит: ${unseeded.join(', ')}`,
).toEqual([]);
const stubSnap = await snapshot(STUB, state.tables);
const foreignSnap = await snapshot(FOREIGN, state.tables);
const empty = state.tables.filter((t) => stubSnap.counts[t]! < 1 || foreignSnap.counts[t]! < 1);
expect(empty, `сид не виден через RLS: ${empty.join(', ')}`).toEqual([]);
expect(Object.values((await snapshot(TARGET, state.tables)).counts).every((n) => n === 0)).toBe(
true,
);
expect(foreign.seeded.size).toBe(stub.seeded.size);
state.before = { stub: stubSnap, foreign: foreignSnap };
});
it('[PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает', async () => {
const before = state.before;
expect(before, 'сид не выполнен (см. [PILOT-REHOME-SEED])').toBeDefined();
if (!existsSync(scriptPath)) {
throw new Error(
'scripts/rehome-tenant.mjs отсутствует (TASK-CODEX-2 п.2.2): перенос заглушки ' +
`${STUB} → ${TARGET} не реализован; контракт сценария — NOTES-FOR-EXECUTORS.md C6. ` +
`Засеяно таблиц: ${state.tables.length}, ledger SUM(amount_minor)=${before!.stub.ledgerSum}`,
);
}
const adminUrl = ctx().adminDatabaseUrl;
if (!adminUrl) {
throw new Error('нет админа БД: задайте E2E_DB_ADMIN_URL или E2E_PG_ADMIN_URL');
}
const expectedTables = Object.fromEntries(
Object.entries(before!.stub.counts).filter(([, n]) => n > 0),
);
// 1. --dry-run: печатает те же числа и ничего не меняет.
const dry = runRehome(['--dry-run', '--json'], adminUrl);
expect(dry.status, `dry-run exit: ${dry.stderr}`).toBe(0);
expect(dry.report, `dry-run без JSON-отчёта: ${dry.stdout}`).not.toBeNull();
const dryTables = Object.fromEntries(
Object.entries(dry.report!.tables ?? {}).filter(([, n]) => Number(n) > 0),
);
expect(dryTables).toEqual(expectedTables);
expect(String(dry.report!.ledger_amount_minor_sum)).toBe(before!.stub.ledgerSum);
expect(await snapshot(STUB, state.tables), '--dry-run изменил данные заглушки').toEqual(
before!.stub,
);
expect(Object.values((await snapshot(TARGET, state.tables)).counts).every((n) => n === 0)).toBe(
true,
);
// 2. Перенос ролью администратора БД.
const real = runRehome(['--json'], adminUrl);
expect(real.status, `перенос exit: ${real.stderr}`).toBe(0);
const afterStub = await snapshot(STUB, state.tables);
const afterTarget = await snapshot(TARGET, state.tables);
const leftovers = state.tables.filter((t) => afterStub.counts[t] !== 0);
expect(leftovers, 'под заглушкой остались строки').toEqual([]);
for (const table of state.tables) {
const expected = before!.stub.counts[table]!;
if (table === 'staff_audit_log')
expect(
afterTarget.counts[table]!,
'staff_audit_log: у нового арендатора нет новой записи о переносе',
).toBeGreaterThan(expected);
else expect(afterTarget.counts[table], table).toBe(expected);
}
expect(afterTarget.ledgerSum).toBe(before!.stub.ledgerSum);
expect(await snapshot(FOREIGN, state.tables), 'посторонний арендатор изменён').toEqual(
before!.foreign,
);
const distinct = await withMigrator(TARGET, async (client) => {
const found: string[] = [];
for (const table of state.tables) {
const r = await client.query<{ tenant_id: string }>(
`SELECT DISTINCT tenant_id FROM ${table} WHERE tenant_id IN ($1, $2)`,
[STUB, TARGET],
);
found.push(...r.rows.map((row) => `${table}:${row.tenant_id}`));
}
return found;
});
expect(distinct.filter((d) => d.endsWith(STUB))).toEqual([]);
// 3. Запись аудита у нового арендатора.
const audit = await dbQueryAs<{ action: string }>(
TARGET,
"SELECT action FROM staff_audit_log WHERE tenant_id = $1 AND action ILIKE '%rehome%'",
[TARGET],
);
expect(
audit.length,
'нет записи staff_audit_log о переносе у нового арендатора',
).toBeGreaterThan(0);
// 4. Роль приложения видит тех же сотрудников под новым tenant и ничего под старым.
const underTarget = await dbQueryAs<{ id: string }>(
TARGET,
'SELECT id FROM staff_employees ORDER BY id',
);
expect(underTarget.map((row) => row.id)).toEqual([...state.employees].sort());
expect(await dbQueryAs(STUB, 'SELECT id FROM staff_employees')).toEqual([]);
// 5. Журнал начислений по-прежнему append-only для приложения.
await expect(
dbQueryAs(TARGET, 'UPDATE staff_payroll_ledger SET amount_minor = amount_minor + 1'),
).rejects.toThrow(/permission denied|append-only|read-only/i);
// 6. Повторный запуск отказывает и ничего не меняет.
const again = runRehome(['--json'], adminUrl);
expect(again.status, 'повторный перенос должен отказать').not.toBe(0);
expect(await snapshot(TARGET, state.tables)).toEqual(afterTarget);
});
});