From d7b235248a89d9c35cd3df5e0bc0fea35df89e04 Mon Sep 17 00:00:00 2001 From: grok Date: Sat, 26 Sep 2026 16:10:07 +0700 Subject: [PATCH] =?UTF-8?q?test(e2e):=20PILOT-REHOME=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D1=91=D0=BC=D0=BA=D0=B0=20=D0=B1=D1=83=D0=B4=D1=83?= =?UTF-8?q?=D1=89=D0=B5=D0=B3=D0=BE=20scripts/rehome-tenant.mjs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Почему: 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, …) — запрос ролью приложения под заданным арендатором. --- tests/e2e/60-pilot-rehome.e2e.ts | 543 ++++++++++++++++++ tests/e2e/harness/client.ts | 14 +- tests/e2e/known-defects.json | 3 +- .../report-evidence/round3-pilot-rehome.txt | 37 ++ 4 files changed, 593 insertions(+), 4 deletions(-) create mode 100644 tests/e2e/60-pilot-rehome.e2e.ts create mode 100644 tests/e2e/report-evidence/round3-pilot-rehome.txt diff --git a/tests/e2e/60-pilot-rehome.e2e.ts b/tests/e2e/60-pilot-rehome.e2e.ts new file mode 100644 index 0000000..a0d839f --- /dev/null +++ b/tests/e2e/60-pilot-rehome.e2e.ts @@ -0,0 +1,543 @@ +// 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(); + 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); + }); +}); diff --git a/tests/e2e/harness/client.ts b/tests/e2e/harness/client.ts index 78be9ba..2cded98 100644 --- a/tests/e2e/harness/client.ts +++ b/tests/e2e/harness/client.ts @@ -71,12 +71,20 @@ export async function dbQuery { - const context = ctx(); - const client = new pg.Client({ connectionString: context.appDatabaseUrl }); + return dbQueryAs(ctx().tenantId, sql, params); +} + +/** Запрос ролью приложения (krona_staff_app) под указанным арендатором (app.tenant_id). */ +export async function dbQueryAs>( + tenantId: string, + sql: string, + params: unknown[] = [], +): Promise { + const client = new pg.Client({ connectionString: ctx().appDatabaseUrl }); await client.connect(); try { await client.query('BEGIN'); - await client.query("SELECT set_config('app.tenant_id', $1, true)", [context.tenantId]); + await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]); const result = await client.query(sql, params); await client.query('COMMIT'); return result.rows; diff --git a/tests/e2e/known-defects.json b/tests/e2e/known-defects.json index 1d0276c..7fc1c9d 100644 --- a/tests/e2e/known-defects.json +++ b/tests/e2e/known-defects.json @@ -45,6 +45,7 @@ "K9/D3": "параметры пресета официанта (shift_rate_minor и др.) не сохраняются", "S9": "удаление точки с устройством: 23502 null value in column tenant_id (ON DELETE SET NULL на составном FK)", "A4": "ответы 500 содержат SQLSTATE и текст PostgreSQL", - "N12-DATE": "month=2026-13 → 500 (22008) вместо 400" + "N12-DATE": "month=2026-13 → 500 (22008) вместо 400", + "PILOT-REHOME": "нет scripts/rehome-tenant.mjs (TASK-CODEX-2 п.2.2): перенос заглушки 01J00000000000000000000000 на настоящий tenant не реализован" } } diff --git a/tests/e2e/report-evidence/round3-pilot-rehome.txt b/tests/e2e/report-evidence/round3-pilot-rehome.txt new file mode 100644 index 0000000..b0242d2 --- /dev/null +++ b/tests/e2e/report-evidence/round3-pilot-rehome.txt @@ -0,0 +1,37 @@ +# PILOT-REHOME: доказательство, что сценарий ловит ошибки переноса (TASK-GROK-3 п.5) +# Эталон — временный scripts/rehome-tenant.mjs (минимальный UPDATE tenant_id под session_replication_role=replica + запись аудита); +# он НЕ коммитился и удалён после прогона. Команда: node scripts/test/run-e2e.mjs (PG17, admin-режим). + +== эталон (reference), не коммитится + ✓ tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME-SEED] сиды покрывают все таблицы с tenant_id (список из information_schema) 176ms + ✓ tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 340ms + × tests/e2e/30-owner-decisions.e2e.ts > Решения владельца: роли, пакеты зарплаты, согласие на биометрию > [D6/OWN-01] менеджер точки A запускает расчёт для точки B → 403 111ms +== эталон (no-audit), не коммитится + ✓ tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME-SEED] сиды покрывают все таблицы с tenant_id (список из information_schema) 164ms + × tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 191ms + → staff_audit_log: у нового арендатора нет новой записи о переносе: expected 2 to be greater than 2 + FAIL tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает +== эталон (dry-writes), не коммитится + ✓ tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME-SEED] сиды покрывают все таблицы с tenant_id (список из information_schema) 182ms + × tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 88ms + → --dry-run изменил данные заглушки: expected { counts: { …(27) }, …(1) } to deeply equal { counts: { …(27) }, …(1) } + FAIL tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает +== без скрипта + ✓ tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME-SEED] сиды покрывают все таблицы с tenant_id (список из information_schema) 169ms + × tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 4ms + → scripts/rehome-tenant.mjs отсутствует (TASK-CODEX-2 п.2.2): перенос заглушки 01J00000000000000000000000 → 01M2R6AC5652NPT3MG34DKN4ZP не реализован; контракт сценария — NOTES-FOR-EXECUTORS.md C6. Засеяно таблиц: 27, ledger SUM(amount_minor)=313227 + FAIL tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает + +# Мутации эталона (первый прогон, до уточнения сообщений ассертов) +== мутация эталона: no-audit + × tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 196ms + → expected 2 to be greater than 2 +== мутация эталона: no-refuse + × tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 319ms + → повторный перенос должен отказать: expected +0 not to be +0 // Object.is equality +== мутация эталона: skip-table + × tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 195ms + → под заглушкой остались строки: expected [ 'staff_work_items' ] to deeply equal [] +== мутация эталона: dry-writes + × tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 91ms + → expected { counts: { …(27) }, …(1) } to deeply equal { counts: { …(27) }, …(1) }