// 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 --to [--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; ledger_amount_minor_sum?: number | string }; type Snapshot = { counts: Record; ledgerSum: string }; async function withMigrator( tenant: string, fn: (client: pg.Client) => Promise, userId?: string, connectionString = ctx().migrationDatabaseUrl, ): Promise { 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 { 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 { return withMigrator(tenant, async (client) => { const counts: Record = {}; 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(); 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(); // Счётчики сравниваются как числа: count(*) из pg приходит строкой (bigint), C6 тип не фиксирует, // а фильтр `Number(n) > 0` и сравнение суммы журнала и раньше допускали строку. const dryTables = Object.fromEntries( Object.entries(dry.report!.tables ?? {}) .map(([table, n]) => [table, Number(n)] as const) .filter(([, n]) => 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); }); });