diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 1beabd2..1e0cfd7 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -3,6 +3,16 @@ on: push: branches: [main, codex/bootstrap-platform-foundation] pull_request: + +# Все actions закреплены полным SHA коммита (F13); SHA получены `git ls-remote` +# и совпадают на github.com и code.forgejo.org (зеркало), поэтому закрепление +# работает при любом DEFAULT_ACTIONS_URL раннера. +# +# Роли БД как в pilot, но с минимальными привилегиями: POSTGRES_USER сервиса — +# только bootstrap-админ (роль DBA/entrypoint образа), им scripts/test/provision-pg.mjs +# создаёт krona_migrator (NOSUPERUSER, владелец БД) и krona_staff_app (NOBYPASSRLS) +# со случайными паролями. Ни миграции, ни тесты, ни сервер под bootstrap-админом не работают. + jobs: verify: runs-on: docker @@ -10,27 +20,125 @@ jobs: postgres: image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73 env: - POSTGRES_DB: krona_staff - POSTGRES_USER: krona_migrator - POSTGRES_PASSWORD: ci-migration-password - ports: ['5432:5432'] + POSTGRES_DB: postgres + POSTGRES_USER: ci_bootstrap + POSTGRES_PASSWORD: ci-bootstrap-only options: >- - --health-cmd "pg_isready -U krona_migrator -d krona_staff" + --health-cmd "pg_isready -U ci_bootstrap -d postgres" --health-interval 5s --health-timeout 3s --health-retries 20 env: - DATABASE_MIGRATION_URL: postgres://krona_migrator:ci-migration-password@postgres:5432/krona_staff - DATABASE_TEST_URL: postgres://krona_staff_app:ci-app-password@postgres:5432/krona_staff - POSTGRES_APP_PASSWORD: ci-app-password + E2E_PG_ADMIN_URL: postgres://ci_bootstrap:ci-bootstrap-only@postgres:5432/postgres steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: { node-version: 22, cache: npm } - run: npm ci - - run: node scripts/create-app-role.mjs - - run: node scripts/migrate.mjs up - - run: npm run check - - run: npm run format:check + - name: provision roles (migrator NOSUPERUSER, app NOBYPASSRLS) + run: node scripts/test/provision-pg.mjs --out "$RUNNER_TEMP/db.env" --db krona_staff + - name: migrations up -> rollback all -> up -> up (no-op) + run: | + set -a; . "$RUNNER_TEMP/db.env"; set +a + node scripts/test/migrate-roundtrip.mjs roundtrip + - name: npm run check (tenant-isolation on real PostgreSQL as app role) + run: | + set -a; . "$RUNNER_TEMP/db.env"; set +a + unset DATABASE_URL POSTGRES_APP_PASSWORD + npm run check - run: npm audit --audit-level=high - - run: docker build -t krona-staff:${{ forgejo.sha }} . + + # Отдельная задача: prettier сейчас красный по 23 файлам (F14; замер 2026-09-26), и это не должно + # маскировать статус verify/e2e. + format: + runs-on: docker + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: { node-version: 22, cache: npm } + - run: npm ci + - run: npm run format:check + + # Приёмочные сценарии на реальном сервере и PostgreSQL 17. Красные сценарии + # известных открытых дефектов перечислены в tests/e2e/known-defects.json: + # задача зелёная, если стенд работает ([HARNESS]/[CTRL] зелёные) и каждый + # красный сценарий есть в списке; новый красный сценарий или исправленный, + # но не вычеркнутый дефект делают задачу красной. + e2e: + runs-on: docker + services: + postgres: + image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73 + env: + POSTGRES_DB: postgres + POSTGRES_USER: ci_bootstrap + POSTGRES_PASSWORD: ci-bootstrap-only + options: >- + --health-cmd "pg_isready -U ci_bootstrap -d postgres" + --health-interval 5s + --health-timeout 3s + --health-retries 20 + env: + E2E_PG_ADMIN_URL: postgres://ci_bootstrap:ci-bootstrap-only@postgres:5432/postgres + E2E_MIGRATIONS: roundtrip + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: { node-version: 22, cache: npm } + - run: npm ci + - name: e2e (known-defects ratchet) + run: node scripts/test/run-e2e.mjs + - name: e2e table + if: always() + run: cat tests/e2e/evidence/e2e-table.md || true + # tests/e2e/evidence/ в .gitignore: полный вывод vitest, JSON, логи серверов — + # артефакт прогона, а не файлы репозитория (TASK-GROK-2 п.1). + - name: upload e2e evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: e2e-evidence + path: tests/e2e/evidence/ + retention-days: 14 + if-no-files-found: warn + + # Windows 11 (проект разрабатывается под Windows). Windows-раннера у сервера пока + # НЕТ, поэтому задача включается только переменной репозитория + # KRONA_WINDOWS_RUNNER=true (иначе пропускается и не висит в очереди). + # Раннер: forgejo-runner в режиме host с меткой `windows`, Node 22+. + # e2e на Windows идёт, только если задан секрет WINDOWS_E2E_PG_ADMIN_URL + # (bootstrap-админ локальной PostgreSQL 17 на раннере). НЕ ПРОВЕРЕНО: не запускалась. + windows: + if: vars.KRONA_WINDOWS_RUNNER == 'true' + runs-on: windows + env: + E2E_PG_ADMIN_URL: ${{ secrets.WINDOWS_E2E_PG_ADMIN_URL }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: { node-version: 22, cache: npm } + - run: npm ci + - name: spawn without .cmd wrappers + run: node node_modules/vitest/vitest.mjs run tests/e2e/harness/node-commands.test.ts + - name: e2e on Windows (needs local PostgreSQL 17) + if: env.E2E_PG_ADMIN_URL != '' + run: node scripts/test/run-e2e.mjs + - name: upload Windows e2e evidence + if: always() && env.E2E_PG_ADMIN_URL != '' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: e2e-evidence-windows + path: tests/e2e/evidence/ + retention-days: 14 + if-no-files-found: warn + + # docker build требует Docker daemon, которого нет в контейнере метки `docker`. + # Нужен раннер с меткой `docker-host` (хост с доступом к Docker). Пока такого + # раннера нет, задача ждёт в очереди и не блокирует verify/format/e2e. + docker-build: + runs-on: docker-host + needs: [verify] + if: github.event_name == 'push' + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - run: docker build -t "krona-staff:${GITHUB_SHA}" . diff --git a/.gitignore b/.gitignore index 9d8783f..a8e581e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ apps/KronaStaffTerminal.Tests/bin/ apps/KronaStaffTerminal.Tests/obj/ artifacts/ .audit-build/ + +# Результаты прогонов e2e (логи серверов, JSON vitest, таблица) — артефакт CI, не исходники +tests/e2e/evidence/ diff --git a/scripts/test/accept-branch.mjs b/scripts/test/accept-branch.mjs new file mode 100644 index 0000000..5a22339 --- /dev/null +++ b/scripts/test/accept-branch.mjs @@ -0,0 +1,697 @@ +#!/usr/bin/env node +// Приёмка веток исполнителей одной командой (TASK-GROK-3 п.1–2). +// +// node scripts/test/accept-branch.mjs [опции] <ветка> [<ветка>…] +// +// Для основы (--base) и каждой ветки: +// 1. git worktree в $ACCEPT_ROOT (по умолчанию ../krona-staff-acceptance), detached HEAD; +// node_modules — symlink на текущий репозиторий, если package-lock.json совпадает, иначе npm ci; +// 2. оверлей ТЕКУЩЕГО стенда: tests/e2e/** и scripts/test/** из этого checkout +// (tests/integration, src, миграции — версии ветки, не трогаются); +// 3. `npm run check` без переменных БД; +// 4. КАЖДЫЙ tests/integration/*.test.ts отдельно на свежей мигрированной БД PostgreSQL; +// красный — по строке `Test Files` (ловит ошибки beforeAll/afterAll, при которых +// `Tests N passed`), плюс ненулевой exit; +// 5. e2e (scripts/test/run-e2e.mjs, миграции roundtrip) и сравнение с основой по ID сценария; +// 6. попарно `git merge-tree --write-tree` — все ветки между собой и с основой; +// 7. «форматирование без поведения» для веток с `format` в имени и для --format-check <ветка>: +// версия файла из merge-base → prettier (конфиг основы) → сравнение с версией ветки. +// Итог — markdown в stdout (и в --out). Логи — в tests/e2e/evidence/accept/<время>/. +// +// БД: по умолчанию E2E_PG_ADMIN_URL (bootstrap-админ) один раз создаёт роли со случайными +// паролями, дальше только пересоздаёт БД `krona_staff_audit_acc_it`/`krona_acc_e2e` (provision-pg --roles keep). +// Интеграционная БД в этом режиме заведомо изолирована, поэтому для тестов с «предохранителем» +// (codex/fix-live-database: DATABASE_TEST_ISOLATED, DATABASE_TEST_GUARD_TOKEN, таблица +// krona_staff_test_guard, имя БД krona_staff_audit_*) он выставляется со случайным маркером. +// Файл, где все тесты пропущены, — отдельный вердикт SKIP (ничего не проверил). +// Если заданы E2E_MIGRATOR_URL и E2E_APP_URL — роли не создаются и пароли не меняются, +// все прогоны идут последовательно в БД из этих URL со сбросом через откат всех миграций. +// +// Опции: --base (origin/codex/bootstrap-platform-foundation), --format-check +// (повторяемая; ветка проверяется только на формат, без worktree), --skip-check, +// --skip-integration, --skip-e2e, --out <файл.md>. +// Ветка задаётся как `origin/<имя>`, `<имя>` (ищется origin/<имя>, затем локальный ref) или SHA. +import { spawn, spawnSync } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import pg from 'pg'; +import * as prettier from 'prettier'; + +const repoRoot = path.resolve(import.meta.dirname, '../..'); +const acceptRoot = path.resolve( + process.env.ACCEPT_ROOT ?? path.join(repoRoot, '..', 'krona-staff-acceptance'), +); + +// ---------- аргументы ---------- +const argv = process.argv.slice(2); +const opts = { + base: 'origin/codex/bootstrap-platform-foundation', + formatOnly: [], + skipCheck: false, + skipIntegration: false, + skipE2e: false, + out: null, + branches: [], +}; +for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--base') opts.base = argv[++i]; + else if (arg === '--format-check') opts.formatOnly.push(argv[++i]); + else if (arg === '--skip-check') opts.skipCheck = true; + else if (arg === '--skip-integration') opts.skipIntegration = true; + else if (arg === '--skip-e2e') opts.skipE2e = true; + else if (arg === '--out') opts.out = argv[++i]; + else if (arg === '-h' || arg === '--help') { + console.log(readFileSync(new URL(import.meta.url), 'utf8').split('\nimport ')[0]); + process.exit(0); + } else if (arg.startsWith('--')) { + console.error(`неизвестная опция ${arg}`); + process.exit(2); + } else opts.branches.push(arg); +} +if (opts.branches.length === 0 && opts.formatOnly.length === 0) { + console.error('Usage: node scripts/test/accept-branch.mjs [--base ref] […]'); + process.exit(2); +} + +// ---------- утилиты ---------- +const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); +const logDir = path.join(repoRoot, 'tests/e2e/evidence/accept', stamp); +mkdirSync(logDir, { recursive: true }); +const progress = (msg) => process.stderr.write(`[accept] ${msg}\n`); +const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, ''); + +function git(args, { cwd = repoRoot, allowFail = false } = {}) { + const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + if (r.status !== 0 && !allowFail) { + throw new Error(`git ${args.join(' ')} → exit ${r.status}: ${r.stderr}`); + } + return { code: r.status, out: r.stdout, err: r.stderr }; +} + +function run(command, args, { cwd, env, logFile, timeoutMs = 20 * 60_000 }) { + return new Promise((resolve) => { + const child = spawn(command, args, { cwd, env }); + let out = ''; + const timer = setTimeout(() => { + out += `\n[accept] TIMEOUT ${timeoutMs} ms — процесс убит\n`; + child.kill('SIGKILL'); + }, timeoutMs); + for (const stream of [child.stdout, child.stderr]) { + stream.on('data', (chunk) => { + out += chunk.toString(); + }); + } + child.on('close', (code) => { + clearTimeout(timer); + const clean = stripAnsi(out); + if (logFile) writeFileSync(logFile, `$ ${[command, ...args].join(' ')}\n${redact(clean)}`); + resolve({ code: code ?? 1, out: clean }); + }); + }); +} + +/** Пароли в URL не должны попасть в логи/отчёт. */ +function redact(text) { + return text.replace(/(postgres(?:ql)?:\/\/[^:\s/]+:)[^@\s]+@/g, '$1***@'); +} + +/** Окружение без переменных БД и секретов стенда. */ +function cleanEnv(extra = {}) { + const env = {}; + for (const [key, value] of Object.entries(process.env)) { + if (/^(DATABASE_|E2E_|TEST_DATABASE|POSTGRES_|KRONA_)/.test(key)) continue; + env[key] = value; + } + return { ...env, NO_COLOR: '1', FORCE_COLOR: '0', ...extra }; +} + +function resolveRef(name) { + const candidates = name.startsWith('origin/') ? [name] : [`origin/${name}`, name]; + for (const candidate of candidates) { + const r = git(['rev-parse', '--verify', '--quiet', `${candidate}^{commit}`], { + allowFail: true, + }); + if (r.code === 0) return { name, ref: candidate, sha: r.out.trim() }; + } + throw new Error(`ветка ${name} не найдена (ни origin/${name}, ни локально)`); +} + +const slugOf = (name) => + name + .replace(/^origin\//, '') + .replace(/[^A-Za-z0-9._-]+/g, '-') + .slice(0, 80); +const short = (sha) => sha.slice(0, 7); + +function firstError(text) { + const lines = text.split('\n'); + const patterns = [ + /((?:[A-Za-z]+Error|error): .+)$/, + /(AssertionError.+)$/, + /(Error: .+)$/, + /(FAIL .+)$/, + ]; + for (const pattern of patterns) { + for (const line of lines) { + const m = pattern.exec(line.trim()); + if (m) return m[1].replace(/\|/g, '\\|').slice(0, 180); + } + } + return ''; +} + +// ---------- БД ---------- +const presetMigrator = process.env.E2E_MIGRATOR_URL; +const presetApp = process.env.E2E_APP_URL; +const adminUrl = process.env.E2E_PG_ADMIN_URL; +const dbMode = presetMigrator && presetApp ? 'preset' : 'admin'; +let roleUrls = null; + +function withDatabase(url, database) { + const u = new URL(url); + u.pathname = `/${database}`; + return u.toString(); +} + +async function prepareRoles() { + if (dbMode === 'preset') { + roleUrls = { migrator: presetMigrator, app: presetApp }; + return 'роли из E2E_MIGRATOR_URL/E2E_APP_URL (не пересоздавались)'; + } + if (!adminUrl) { + throw new Error('нужен E2E_PG_ADMIN_URL или пара E2E_MIGRATOR_URL + E2E_APP_URL'); + } + const dir = mkdtempSync(path.join(tmpdir(), 'krona-accept-')); + const envFile = path.join(dir, 'db.env'); + const r = await run( + process.execPath, + ['scripts/test/provision-pg.mjs', '--out', envFile, '--db', 'krona_acc_roles'], + { cwd: repoRoot, env: { ...cleanEnv(), E2E_PG_ADMIN_URL: adminUrl } }, + ); + if (r.code !== 0) throw new Error(`provision: ${r.out}`); + const env = Object.fromEntries( + readFileSync(envFile, 'utf8') + .split('\n') + .filter((l) => l.includes('=')) + .map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]), + ); + rmSync(dir, { recursive: true, force: true }); + roleUrls = { migrator: env.DATABASE_MIGRATION_URL, app: env.DATABASE_URL }; + return 'роли созданы один раз через E2E_PG_ADMIN_URL (provision-pg), дальше --roles keep'; +} + +/** Чистая БД: в admin-режиме пересоздаётся, в preset — одна и та же (сброс откатом миграций). */ +async function freshDatabase(name) { + if (dbMode === 'preset') return { ...roleUrls, reset: 'roundtrip' }; + const r = await run( + process.execPath, + ['scripts/test/provision-pg.mjs', '--roles', 'keep', '--db', name], + { cwd: repoRoot, env: { ...cleanEnv(), E2E_PG_ADMIN_URL: adminUrl } }, + ); + if (r.code !== 0) throw new Error(`provision --roles keep ${name}: ${r.out}`); + return { + migrator: withDatabase(roleUrls.migrator, name), + app: withDatabase(roleUrls.app, name), + admin: withDatabase(adminUrl, name), + reset: 'up', + }; +} + +// ---------- worktree ---------- +const repoLockHash = createHash('sha256') + .update(readFileSync(path.join(repoRoot, 'package-lock.json'))) + .digest('hex'); + +async function prepareWorktree(target) { + const dir = path.join(acceptRoot, `wt-${slugOf(target.name)}`); + mkdirSync(acceptRoot, { recursive: true }); + const registered = git(['worktree', 'list', '--porcelain']) + .out.split('\n') + .some((l) => l === `worktree ${dir}`); + if (registered) git(['worktree', 'remove', '--force', dir]); + else if (existsSync(dir)) throw new Error(`${dir} существует, но не является worktree`); + git(['worktree', 'add', '--detach', '--force', dir, target.sha]); + const lock = git(['show', `${target.sha}:package-lock.json`]).out; + const lockSame = createHash('sha256').update(lock).digest('hex') === repoLockHash; + if (lockSame) { + symlinkSync(path.join(repoRoot, 'node_modules'), path.join(dir, 'node_modules'), 'dir'); + } else { + progress(`${target.name}: package-lock.json отличается — npm ci`); + const r = await run('npm', ['ci', '--no-audit', '--no-fund'], { + cwd: dir, + env: cleanEnv(), + logFile: path.join(logDir, `${slugOf(target.name)}-npm-ci.txt`), + }); + if (r.code !== 0) throw new Error(`${target.name}: npm ci exit ${r.code}`); + } + // Оверлей стенда: tests/e2e/** (без evidence) и scripts/test/**. + const overlayTouched = git([ + 'diff', + '--name-only', + `${opts.baseTarget.sha}...${target.sha}`, + '--', + 'tests/e2e', + 'scripts/test', + ]).out.trim(); + for (const rel of ['tests/e2e', 'scripts/test']) { + rmSync(path.join(dir, rel), { recursive: true, force: true }); + cpSync(path.join(repoRoot, rel), path.join(dir, rel), { + recursive: true, + filter: (src) => !src.includes(`${path.sep}tests${path.sep}e2e${path.sep}evidence`), + }); + } + return { + dir, + deps: lockSame ? 'symlink' : 'npm ci', + overlayTouched: overlayTouched ? overlayTouched.split('\n') : [], + }; +} + +// ---------- шаги ---------- +async function stepCheck(target, wt) { + progress(`${target.name}: npm run check`); + const r = await run('npm', ['run', 'check'], { + cwd: wt.dir, + env: cleanEnv(), + logFile: path.join(logDir, `${slugOf(target.name)}-check.txt`), + }); + const tests = /Tests\s+(.+)$/m.exec(r.out)?.[1]?.trim() ?? ''; + return { ok: r.code === 0, code: r.code, tests, error: r.code === 0 ? '' : firstError(r.out) }; +} + +async function migrate(wt, db, logFile) { + const r = await run(process.execPath, ['scripts/test/migrate-roundtrip.mjs', db.reset], { + cwd: wt.dir, + env: cleanEnv({ DATABASE_MIGRATION_URL: db.migrator }), + logFile, + }); + return r; +} + +/** Предохранитель изолированной тестовой БД (соглашение теста codex/fix-live-database). */ +async function createGuard(db) { + const marker = randomBytes(16).toString('hex'); + const client = new pg.Client({ connectionString: db.migrator }); + await client.connect(); + try { + await client.query( + 'CREATE TABLE IF NOT EXISTS krona_staff_test_guard (marker text PRIMARY KEY)', + ); + await client.query('INSERT INTO krona_staff_test_guard (marker) VALUES ($1)', [marker]); + await client.query('GRANT SELECT ON krona_staff_test_guard TO krona_staff_app'); + } finally { + await client.end(); + } + return marker; +} + +async function stepIntegration(target, wt) { + const dir = path.join(wt.dir, 'tests/integration'); + const files = existsSync(dir) + ? readdirSync(dir) + .filter((f) => f.endsWith('.test.ts')) + .sort() + : []; + const results = []; + for (const file of files) { + progress(`${target.name}: integration ${file}`); + const slug = `${slugOf(target.name)}-it-${file.replace(/\.test\.ts$/, '')}`; + const db = await freshDatabase('krona_staff_audit_acc_it'); + const m = await migrate(wt, db, path.join(logDir, `${slug}-migrate.txt`)); + const guard = m.code === 0 && dbMode === 'admin' ? await createGuard(db) : null; + if (m.code !== 0) { + results.push({ + file, + ok: false, + files: '', + tests: '', + error: `миграции: ${firstError(m.out)}`, + }); + continue; + } + const r = await run( + process.execPath, + [path.join(wt.dir, 'node_modules/vitest/vitest.mjs'), 'run', `tests/integration/${file}`], + { + cwd: wt.dir, + env: cleanEnv({ + DATABASE_TEST_URL: db.app, + DATABASE_TEST_MIGRATION_URL: db.migrator, + DATABASE_MIGRATION_URL: db.migrator, + ...(guard ? { DATABASE_TEST_ISOLATED: 'true', DATABASE_TEST_GUARD_TOKEN: guard } : {}), + }), + logFile: path.join(logDir, `${slug}.txt`), + }, + ); + const filesLine = /Test Files\s+(.+)$/m.exec(r.out)?.[1]?.trim() ?? '(нет строки Test Files)'; + const testsLine = /^\s*Tests\s+(.+)$/m.exec(r.out)?.[1]?.trim() ?? ''; + const ok = r.code === 0 && /\bpassed\b/.test(filesLine) && !/failed/.test(filesLine); + const skipped = r.code === 0 && /skipped/.test(filesLine) && !/passed|failed/.test(filesLine); + results.push({ + file, + ok, + skipped, + files: filesLine, + tests: testsLine, + error: ok || skipped ? '' : firstError(r.out), + }); + } + return results; +} + +function loadE2e(file) { + const map = new Map(); + for (const f of JSON.parse(readFileSync(file, 'utf8')).testResults) { + for (const t of f.assertionResults) { + const id = /^\[([A-Z0-9/-]+)\]/.exec(t.title)?.[1] ?? 'UNTAGGED'; + const e = map.get(id) ?? { pass: 0, fail: 0, msg: '' }; + if (t.status === 'passed') e.pass += 1; + else if (t.status === 'failed') { + e.fail += 1; + e.msg ||= stripAnsi(t.failureMessages?.[0] ?? '') + .split('\n')[0] + .replace(/\s+/g, ' ') + .replace(/\|/g, '\\|') + .slice(0, 160); + } + map.set(id, e); + } + } + return map; +} + +async function stepE2e(target, wt) { + progress(`${target.name}: e2e`); + const db = await freshDatabase('krona_acc_e2e'); + const slug = slugOf(target.name); + const env = cleanEnv({ + E2E_MIGRATOR_URL: db.migrator, + E2E_APP_URL: db.app, + E2E_MIGRATIONS: 'roundtrip', + ...(db.admin ? { E2E_DB_ADMIN_URL: db.admin } : {}), + }); + const r = await run(process.execPath, ['scripts/test/run-e2e.mjs'], { + cwd: wt.dir, + env, + logFile: path.join(logDir, `${slug}-e2e.txt`), + }); + const jsonPath = path.join(wt.dir, 'tests/e2e/evidence/e2e-results.json'); + if (!existsSync(jsonPath)) + return { ok: false, code: r.code, map: null, error: firstError(r.out) }; + cpSync(jsonPath, path.join(logDir, `${slug}-e2e-results.json`)); + const map = loadE2e(jsonPath); + const red = [...map.values()].filter((e) => e.fail > 0).length; + const verdict = /\[run-e2e\] (OK|FAIL)[^\n]*/.exec(r.out)?.[0] ?? ''; + return { ok: r.code === 0, code: r.code, map, red, green: map.size - red, verdict }; +} + +function diffE2e(baseMap, map) { + const color = (e) => (e ? (e.fail ? 'RED' : 'GREEN') : 'нет'); + const turnedGreen = []; + const turnedRed = []; + const other = []; + for (const id of [...new Set([...baseMap.keys(), ...map.keys()])].sort()) { + const a = color(baseMap.get(id)); + const b = color(map.get(id)); + if (a === b) continue; + if (a === 'RED' && b === 'GREEN') turnedGreen.push(id); + else if (a === 'GREEN' && b === 'RED') turnedRed.push(`${id} (${map.get(id).msg})`); + else other.push(`${id}: ${a}→${b}`); + } + return { turnedGreen, turnedRed, other }; +} + +function mergeTree(a, b) { + const r = git(['merge-tree', '--write-tree', '--name-only', '--no-messages', a.sha, b.sha], { + allowFail: true, + }); + if (r.code === 0) return { status: 'чисто', files: [] }; + if (r.code === 1) { + const files = r.out.split('\n').slice(1).filter(Boolean); + return { status: 'КОНФЛИКТ', files }; + } + return { status: `ошибка git (exit ${r.code})`, files: [r.err.trim()] }; +} + +// ---------- форматирование без поведения ---------- +async function prettierConfigFor(file) { + const anchor = path.join(opts.baseWorktree ?? repoRoot, file); + const config = (await prettier.resolveConfig(anchor)) ?? {}; + const info = await prettier.getFileInfo(anchor, { + ignorePath: path.join(opts.baseWorktree ?? repoRoot, '.prettierignore'), + }); + return { config, info }; +} + +async function normalize(content, file) { + const { config, info } = await prettierConfigFor(file); + if (info.ignored || !info.inferredParser) return content; + return prettier.format(content, { ...config, filepath: file }); +} + +function showFile(sha, file) { + const r = git(['show', `${sha}:${file}`], { allowFail: true }); + return r.code === 0 ? r.out : null; +} + +function changedLines(before, after) { + const dir = mkdtempSync(path.join(tmpdir(), 'krona-fmt-')); + writeFileSync(path.join(dir, 'a'), before); + writeFileSync(path.join(dir, 'b'), after); + const r = git(['diff', '--no-index', '-U0', '--no-color', 'a', 'b'], { + cwd: dir, + allowFail: true, + }); + rmSync(dir, { recursive: true, force: true }); + return r.out + .split('\n') + .filter((l) => /^[+-]/.test(l) && !/^(\+\+\+|---) /.test(l)) + .map((l) => `${l[0]}${l.slice(1).trim()}`) + .filter((l) => l.length > 1); +} + +async function formatCheck(target, others) { + const mergeBase = git(['merge-base', opts.baseTarget.sha, target.sha]).out.trim(); + const changes = git(['diff', '--name-status', '--no-renames', mergeBase, target.sha]) + .out.trim() + .split('\n') + .filter(Boolean) + .map((l) => ({ status: l[0], file: l.split('\t')[1] })); + const rows = []; + for (const { status, file } of changes) { + const touchesConfig = /(^|\/)\.prettier(rc|ignore)|prettier\.config\./.test(file); + if (status === 'D') { + rows.push({ file, verdict: 'удалён', lines: 0, same: [] }); + continue; + } + const after = showFile(target.sha, file); + const before = status === 'A' ? null : showFile(mergeBase, file); + if (before === null) { + rows.push({ file, verdict: 'новый файл', lines: 0, same: [] }); + continue; + } + const formatted = await normalize(before, file); + if (formatted === after && !touchesConfig) continue; // чистое форматирование + const lines = changedLines(formatted, after); + const same = []; + for (const other of others) { + const otherContent = showFile(other.sha, file); + if (otherContent === null) continue; + const otherMergeBase = git(['merge-base', opts.baseTarget.sha, other.sha]).out.trim(); + const otherBase = showFile(otherMergeBase, file) ?? ''; + const otherLines = new Set( + changedLines(await normalize(otherBase, file), await normalize(otherContent, file)), + ); + const matched = lines.filter((l) => otherLines.has(l)).length; + if (matched > 0) same.push(`${other.name}: ${matched}/${lines.length}`); + } + rows.push({ + file, + verdict: touchesConfig ? 'меняет конфиг prettier' : 'не совпадает с prettier(база)', + lines: lines.length, + same, + }); + } + return { mergeBase, files: changes.length, rows }; +} + +// ---------- main ---------- +opts.baseTarget = resolveRef(opts.base); +const targets = opts.branches.map(resolveRef); +const formatTargets = [ + ...targets.filter((t) => /format/i.test(t.name)), + ...opts.formatOnly.map(resolveRef), +]; +const harnessHead = git(['rev-parse', '--short', 'HEAD']).out.trim(); +const harnessDirty = git(['status', '--porcelain', '--', 'tests/e2e', 'scripts/test']).out.trim() + ? ' (+ незакоммиченные изменения стенда)' + : ''; + +const needRuns = !(opts.skipCheck && opts.skipIntegration && opts.skipE2e) && targets.length > 0; +let dbNote = 'не использовалась'; +if (needRuns && !(opts.skipIntegration && opts.skipE2e)) dbNote = await prepareRoles(); + +const results = new Map(); +const all = [opts.baseTarget, ...targets]; +for (const target of all) { + const res = { target }; + results.set(target, res); + if (!needRuns && target !== opts.baseTarget) continue; + try { + const wt = await prepareWorktree(target); + res.wt = wt; + if (target === opts.baseTarget) opts.baseWorktree = wt.dir; + if (!needRuns) continue; + if (!opts.skipCheck) res.check = await stepCheck(target, wt); + if (!opts.skipIntegration) res.integration = await stepIntegration(target, wt); + if (!opts.skipE2e) res.e2e = await stepE2e(target, wt); + } catch (error) { + res.fatal = redact(String(error?.message ?? error)).slice(0, 300); + progress(`${target.name}: ${res.fatal}`); + } +} + +const formatResults = []; +for (const target of formatTargets) { + progress(`${target.name}: проверка форматирования`); + const others = targets.filter((t) => t !== target && !/format/i.test(t.name)); + formatResults.push({ target, ...(await formatCheck(target, others)) }); +} + +// ---------- markdown ---------- +const md = []; +const baseRes = results.get(opts.baseTarget); +md.push(`## accept-branch — ${new Date().toISOString().slice(0, 16).replace('T', ' ')} UTC`); +md.push(''); +md.push( + `База: \`${opts.baseTarget.ref}\` = ${short(opts.baseTarget.sha)}; стенд (оверлей tests/e2e, scripts/test): ${harnessHead}${harnessDirty}; БД: ${dbNote}. Логи: \`${path.relative(repoRoot, logDir)}/\`.`, +); +md.push(''); +if (needRuns) { + md.push('### Сводка'); + md.push(''); + md.push( + '| Ветка | SHA | npm run check | integration (красные файлы) | e2e red/green | e2e: стало зелёным | e2e: стало красным |', + ); + md.push('|---|---|---|---|---|---|---|'); + for (const res of results.values()) { + const t = res.target; + const isBase = t === opts.baseTarget; + const name = isBase ? `${t.ref} (база)` : t.ref; + if (res.fatal) { + md.push(`| ${name} | ${short(t.sha)} | ОШИБКА: ${res.fatal} | | | | |`); + continue; + } + const check = res.check + ? res.check.ok + ? `OK (${res.check.tests})` + : `FAIL exit=${res.check.code}: ${res.check.error}` + : 'не запускался'; + const reds = res.integration?.filter((r) => !r.ok && !r.skipped).map((r) => r.file) ?? []; + const skips = res.integration?.filter((r) => r.skipped).map((r) => r.file) ?? []; + const greens = (res.integration?.length ?? 0) - reds.length - skips.length; + const integration = res.integration + ? `${greens}/${res.integration.length} зелёных${reds.length ? `; FAIL: ${reds.join(', ')}` : ''}${skips.length ? `; SKIP (файл пропущен целиком): ${skips.join(', ')}` : ''}` + : 'не запускались'; + let e2e = 'не запускался'; + let green = ''; + let red = ''; + if (res.e2e?.map) { + e2e = `${res.e2e.red}/${res.e2e.green}`; + if (!isBase && baseRes.e2e?.map) { + const d = diffE2e(baseRes.e2e.map, res.e2e.map); + green = d.turnedGreen.join(', ') || '—'; + red = [...d.turnedRed, ...d.other].join('; ') || '—'; + } + } else if (res.e2e) e2e = `стенд не поднялся: ${res.e2e.error}`; + if (isBase) { + green = 'n/a'; + red = 'n/a'; + } + md.push( + `| ${name} | ${short(t.sha)} | ${check} | ${integration} | ${e2e} | ${green} | ${red} |`, + ); + } + md.push(''); + if ( + results.values().next().value.integration || + targets.some((t) => results.get(t).integration) + ) { + md.push('### Интеграционные тесты (каждый файл отдельно, свежая БД)'); + md.push(''); + md.push('| Ветка | Файл | Итог | Test Files | Tests | Первая ошибка |'); + md.push('|---|---|---|---|---|---|'); + for (const res of results.values()) { + for (const r of res.integration ?? []) { + md.push( + `| ${res.target.ref} | ${r.file} | ${r.ok ? 'OK' : r.skipped ? 'SKIP' : 'FAIL'} | ${r.files} | ${r.tests} | ${r.error} |`, + ); + } + } + md.push(''); + } + const overlayNotes = [...results.values()].filter((r) => r.wt?.overlayTouched.length); + if (overlayNotes.length) { + md.push('Внимание: ветка меняла файлы стенда, при приёмке они заменены оверлеем:'); + for (const r of overlayNotes) md.push(`- ${r.target.ref}: ${r.wt.overlayTouched.join(', ')}`); + md.push(''); + } + const npmCi = [...results.values()].filter((r) => r.wt?.deps === 'npm ci'); + if (npmCi.length) + md.push(`npm ci (lock отличается): ${npmCi.map((r) => r.target.ref).join(', ')}\n`); +} + +if (targets.length > 0) { + md.push('### Попарные слияния (`git merge-tree --write-tree`)'); + md.push(''); + md.push('| Ветка A | Ветка B | Результат | Конфликтные файлы |'); + md.push('|---|---|---|---|'); + const pairs = []; + for (const t of targets) pairs.push([opts.baseTarget, t]); + for (let i = 0; i < targets.length; i += 1) { + for (let j = i + 1; j < targets.length; j += 1) pairs.push([targets[i], targets[j]]); + } + for (const [a, b] of pairs) { + const r = mergeTree(a, b); + md.push(`| ${a.ref} | ${b.ref} | ${r.status} | ${r.files.join(', ')} |`); + } + md.push(''); +} + +if (formatResults.length) { + md.push('### Форматирование без поведения'); + md.push(''); + for (const f of formatResults) { + const verdict = f.rows.length === 0 ? 'ТОЛЬКО ФОРМАТИРОВАНИЕ' : 'ЕСТЬ ИЗМЕНЕНИЯ СВЕРХ PRETTIER'; + md.push( + `**${f.target.ref}** (${short(f.target.sha)}), merge-base ${short(f.mergeBase)}, изменено файлов: ${f.files} — ${verdict}`, + ); + md.push(''); + if (f.rows.length) { + md.push('| Файл | Почему | Строк сверх prettier | Эти же строки в ветке |'); + md.push('|---|---|---|---|'); + for (const r of f.rows) { + md.push(`| ${r.file} | ${r.verdict} | ${r.lines} | ${r.same.join('; ') || '—'} |`); + } + md.push(''); + } + } +} + +const text = redact(md.join('\n')); +writeFileSync(path.join(logDir, 'summary.md'), text); +if (opts.out) writeFileSync(opts.out, text); +console.log(text); diff --git a/scripts/test/ci-local.sh b/scripts/test/ci-local.sh new file mode 100755 index 0000000..aec3f08 --- /dev/null +++ b/scripts/test/ci-local.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Локальный эквивалент задач verify/format/e2e из .forgejo/workflows/ci.yml +# (без Docker: PostgreSQL 17 уже запущен, E2E_PG_ADMIN_URL указывает на bootstrap-админа). +# docker-build локально не воспроизводится: Docker в этой среде нет. +set -uo pipefail +cd "$(dirname "$0")/../.." +: "${E2E_PG_ADMIN_URL:?E2E_PG_ADMIN_URL (bootstrap-админ PostgreSQL 17) обязателен}" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +status=0 +step() { + local name="$1" + shift + echo "::: [$name] $*" + if "$@"; then echo "::: [$name] OK"; else local code=$?; echo "::: [$name] FAIL exit=$code"; status=1; fi +} +with_db_env() { (set -a; . "$tmp/db.env"; set +a; unset DATABASE_URL POSTGRES_APP_PASSWORD; "$@"); } + +echo "=== job verify" +step npm-ci npm ci --no-audit --no-fund +step provision node scripts/test/provision-pg.mjs --out "$tmp/db.env" --db krona_staff +step migrate-roundtrip with_db_env node scripts/test/migrate-roundtrip.mjs roundtrip +step check with_db_env npm run check +step audit npm audit --audit-level=high +echo "=== job format" +step format-check npm run format:check +echo "=== job e2e" +step e2e env E2E_MIGRATIONS=roundtrip node scripts/test/run-e2e.mjs +echo "=== итог: status=$status" +exit $status diff --git a/scripts/test/migrate-roundtrip.mjs b/scripts/test/migrate-roundtrip.mjs new file mode 100644 index 0000000..7567631 --- /dev/null +++ b/scripts/test/migrate-roundtrip.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +// up -> откат ВСЕХ миграций по одной -> up -> повторный up (идемпотентность). +// Запускается ролью мигратора (DATABASE_MIGRATION_URL), без SUPERUSER. +import { spawnSync } from 'node:child_process'; + +function run(direction) { + const result = spawnSync(process.execPath, ['scripts/migrate.mjs', direction], { + env: { ...process.env, ALLOW_MIGRATION_DOWN: direction === 'down' ? 'true' : 'false' }, + encoding: 'utf8', + }); + process.stdout.write(result.stdout); + process.stderr.write(result.stderr); + if (result.status !== 0) { + console.error(`migrate ${direction} failed with exit ${result.status}`); + process.exit(result.status ?? 1); + } + return result.stdout; +} + +const mode = process.argv[2] ?? 'roundtrip'; +console.log('== migrate up (1)'); +const first = run('up'); +if (mode === 'up') process.exit(0); +const applied = (first.match(/^applied /gm) ?? []).length; +console.log(`== rollback all (applied now: ${applied})`); +let rolledBack = 0; +for (let i = 0; i < 1000; i += 1) { + const out = run('down'); + if (/no migration to roll back/.test(out)) break; + rolledBack += 1; +} +console.log(`== rolled back ${rolledBack} migrations`); +console.log('== migrate up (2)'); +const second = run('up'); +const reapplied = (second.match(/^applied /gm) ?? []).length; +console.log('== migrate up (3, must be no-op)'); +const third = run('up'); +if ((third.match(/^applied /gm) ?? []).length !== 0) { + console.error('third up applied migrations again: not idempotent'); + process.exit(1); +} +if (rolledBack === 0 || reapplied !== rolledBack) { + console.error(`rollback/reapply mismatch: rolledBack=${rolledBack} reapplied=${reapplied}`); + process.exit(1); +} +console.log(`== roundtrip OK: rolledBack=${rolledBack} reapplied=${reapplied}`); diff --git a/scripts/test/n13-race.mjs b/scripts/test/n13-race.mjs new file mode 100644 index 0000000..dd113d6 --- /dev/null +++ b/scripts/test/n13-race.mjs @@ -0,0 +1,300 @@ +#!/usr/bin/env node +// N13: гонка первого расчёта зарплаты. N параллельных POST /payroll/calculate для одной и той же +// пары (точка, месяц), когда расчёта ещё нет. Каждая попытка — новая точка (значит «первый» расчёт). +// +// Режимы: +// --mode as-is код и схема как есть (N2 не обходится); +// --mode n2-bypass ДИАГНОСТИКА: в одноразовой БД мигратор ставит DEFAULT now() для +// staff_outbox.occurred_at, чтобы обойти N2 и увидеть саму гонку. +// Продукт/миграции не меняются, БД удаляется при следующем provision. +// Опции: --sizes 10,25,50 --attempts 5 --with-sessions (подтверждённые смены, нужен n2-bypass) +// Требует E2E_PG_ADMIN_URL (bootstrap-админ только для создания ролей/БД). +// Правильное поведение: все ответы 200, один и тот же run_id, ровно один расчёт в БД, +// каждый возвращённый run_id существует в БД. +import { spawn, spawnSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { createWriteStream, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import pg from 'pg'; +import { baseEnv, nodeWithTsx } from './node-commands.mjs'; + +const repoRoot = path.resolve(import.meta.dirname, '../..'); +const args = process.argv.slice(2); +const opt = (name, fallback) => { + const index = args.indexOf(`--${name}`); + return index >= 0 ? args[index + 1] : fallback; +}; +const mode = opt('mode', 'as-is'); +const sizes = opt('sizes', '10,25,50').split(',').map(Number); +const attempts = Number(opt('attempts', '5')); +const withSessions = args.includes('--with-sessions'); +const database = opt('db', `krona_staff_n13_${mode.replace(/-/g, '_')}`); + +const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; +const ulid = () => + Array.from(randomBytes(26), (byte) => CROCKFORD[byte % 32]) + .join('') + .replace(/^./, '0'); + +function parseEnvFile(file) { + const result = {}; + for (const line of readFileSync(file, 'utf8').split('\n')) { + const index = line.indexOf('='); + if (index > 0) result[line.slice(0, index)] = line.slice(index + 1); + } + return result; +} + +function runNode(scriptArgs, env) { + const result = spawnSync(process.execPath, scriptArgs, { cwd: repoRoot, env, encoding: 'utf8' }); + if (result.status !== 0) { + process.stdout.write(`${result.stdout}${result.stderr}`); + throw new Error(`${scriptArgs.join(' ')} failed`); + } + return result.stdout; +} + +async function freePort() { + return new Promise((resolve) => { + const server = createServer(); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + server.close(() => resolve(port)); + }); + }); +} + +const adminUrl = process.env.E2E_PG_ADMIN_URL; +if (!adminUrl) throw new Error('E2E_PG_ADMIN_URL не задан'); +const secretsDir = mkdtempSync(path.join(tmpdir(), 'krona-n13-')); +runNode( + ['scripts/test/provision-pg.mjs', '--out', path.join(secretsDir, 'db.env'), '--db', database], + { + ...process.env, + }, +); +const dbEnv = parseEnvFile(path.join(secretsDir, 'db.env')); +rmSync(secretsDir, { recursive: true, force: true }); +runNode(['scripts/test/migrate-roundtrip.mjs', 'up'], { + ...baseEnv(), + DATABASE_MIGRATION_URL: dbEnv.DATABASE_MIGRATION_URL, +}); +const migrator = new pg.Client({ connectionString: dbEnv.DATABASE_MIGRATION_URL }); +await migrator.connect(); +if (mode === 'n2-bypass') { + await migrator.query('ALTER TABLE staff_outbox ALTER COLUMN occurred_at SET DEFAULT now()'); + console.log( + '[n13] ДИАГНОСТИКА: staff_outbox.occurred_at DEFAULT now() (обход N2 только в этой БД)', + ); +} + +const tenantId = ulid(); +const managerToken = randomBytes(32).toString('hex'); +const terminalToken = randomBytes(32).toString('hex'); +const vapid = {}; +for (const line of runNode(['scripts/generate-vapid-keys.mjs'], process.env).split('\n')) { + const index = line.indexOf('='); + if (index > 0) vapid[line.slice(0, index)] = line.slice(index + 1); +} +const port = await freePort(); +const log = createWriteStream(path.join(repoRoot, `tests/e2e/evidence/server-n13-${mode}.txt`)); +const server = nodeWithTsx(['src/server.ts']); +const child = spawn(server.command, server.args, { + cwd: repoRoot, + env: { + ...baseEnv(), + NODE_ENV: 'test', + HOST: '127.0.0.1', + PORT: String(port), + LOG_LEVEL: 'warn', + STAFF_RUNTIME_MODE: 'standalone', + DATABASE_URL: dbEnv.DATABASE_URL, + STANDALONE_TENANT_ID: tenantId, + STANDALONE_ACTOR_ID: ulid(), + STANDALONE_ACCESS: 'employee', + STANDALONE_MANAGER_TOKEN: managerToken, + STANDALONE_TERMINAL_TOKEN: terminalToken, + STANDALONE_TERMINAL_ACTOR_ID: ulid(), + ...vapid, + }, + stdio: ['ignore', 'pipe', 'pipe'], +}); +child.stdout.pipe(log); +child.stderr.pipe(log); +const base = `http://127.0.0.1:${port}/api/v1/staff`; + +async function api(method, route, token, body) { + const response = await fetch(`${base}${route}`, { + method, + headers: { + authorization: `Bearer ${token}`, + ...(body ? { 'content-type': 'application/json' } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + const text = await response.text(); + let json = null; + try { + json = JSON.parse(text); + } catch { + // не JSON + } + return { status: response.status, json, text }; +} + +try { + for (let i = 0; i < 240; i += 1) { + try { + if ((await fetch(`${base}/ready`)).status === 200) break; + } catch { + // ещё не слушает + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + await migrator.query("SELECT set_config('app.tenant_id', $1, false)", [tenantId]); + + const month = (() => { + const date = new Date(); + date.setUTCDate(1); + date.setUTCMonth(date.getUTCMonth() - 1); + return date.toISOString().slice(0, 7); + })(); + let hourlyRule = null; + if (withSessions) { + const rules = await api('GET', '/pay-rules', managerToken); + hourlyRule = ( + rules.json.pay_rules.find((rule) => rule.code === 'hourly') ?? rules.json.pay_rules[0] + ).id; + } + + async function prepareUnit() { + const code = `n13-${ulid().slice(-10).toLowerCase()}`; + const suffix = ulid().slice(-8); + const imported = await api('POST', '/imports/owner-workbook', managerToken, { + source_name: `n13-import-${suffix}`, + units: [{ code, name: `Синтетическая точка ${suffix}` }], + ...(withSessions + ? { + employees: [ + { + external_ref: `n13:${suffix}`, + personnel_number: `N13-${suffix}`, + display_name: `Синтетический ${suffix}`, + }, + ], + } + : {}), + }); + if (imported.status >= 300) throw new Error(`import ${imported.status} ${imported.text}`); + const unitId = (await migrator.query('SELECT id FROM staff_org_units WHERE code = $1', [code])) + .rows[0].id; + if (withSessions) { + const employeeId = ( + await migrator.query('SELECT id FROM staff_employees WHERE external_ref = $1', [ + `n13:${suffix}`, + ]) + ).rows[0].id; + const assign = await api('PUT', `/employees/${employeeId}/pay-assignment`, managerToken, { + pay_rule_id: hourlyRule, + org_unit_id: unitId, + rate_minor: 12345, + effective_from: '2026-01-01', + }); + if (assign.status !== 200) throw new Error(`assign ${assign.status} ${assign.text}`); + const start = new Date(`${month}-10T09:00:00Z`); + for (const [eventType, at] of [ + ['check_in', start], + ['check_out', new Date(start.getTime() + 8 * 3_600_000)], + ]) { + const event = await api('POST', '/attendance/events', terminalToken, { + employee_id: employeeId, + event_type: eventType, + occurred_at: at.toISOString(), + source_event_id: `n13-${ulid()}`, + org_unit_id: unitId, + }); + if (event.status !== 201) throw new Error(`clock ${event.status} ${event.text}`); + } + const day = start.toISOString().slice(0, 10); + const reconcile = await api('POST', '/work-sessions/reconcile', managerToken, { + date_from: day, + date_to: day, + }); + if (reconcile.status !== 200) throw new Error(`reconcile ${reconcile.status}`); + const sessionId = ( + await migrator.query('SELECT id FROM staff_work_sessions WHERE employee_id = $1', [ + employeeId, + ]) + ).rows[0].id; + const confirm = await api('PATCH', `/work-sessions/${sessionId}`, managerToken, { + status: 'confirmed', + }); + if (confirm.status !== 200) + throw new Error(`confirm ${confirm.status} ${confirm.text.slice(0, 200)}`); + } + return unitId; + } + + let totalBad = 0; + const summary = []; + for (const size of sizes) { + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const unitId = await prepareUnit(); + const responses = await Promise.all( + Array.from({ length: size }, () => + api('POST', '/payroll/calculate', managerToken, { month, org_unit_id: unitId }), + ), + ); + const statuses = {}; + const errors = {}; + const runIds = new Set(); + for (const response of responses) { + statuses[response.status] = (statuses[response.status] ?? 0) + 1; + if (response.status === 200) { + const id = response.json?.run_id ?? response.json?.id ?? response.json?.run?.id; + if (id) runIds.add(id); + } else { + const key = ( + response.json?.error?.message ?? + response.json?.message ?? + response.text + ).slice(0, 90); + errors[key] = (errors[key] ?? 0) + 1; + } + } + const dbRuns = ( + await migrator.query('SELECT id FROM staff_payroll_runs WHERE org_unit_id = $1', [unitId]) + ).rows.map((row) => row.id); + const dbLines = Number( + ( + await migrator.query( + 'SELECT count(*) AS n FROM staff_payroll_lines WHERE org_unit_id = $1', + [unitId], + ) + ).rows[0].n, + ); + const phantom = [...runIds].filter((id) => !dbRuns.includes(id)); + const ok = + (statuses[200] ?? 0) === size && + runIds.size === 1 && + dbRuns.length === 1 && + phantom.length === 0; + if (!ok) totalBad += 1; + const line = + `[n13] mode=${mode} sessions=${withSessions} N=${size} attempt=${attempt}: ` + + `statuses=${JSON.stringify(statuses)} distinct_run_ids=${runIds.size} db_runs=${dbRuns.length} db_lines=${dbLines} ` + + `phantom_run_ids=${phantom.length} errors=${JSON.stringify(errors)} -> ${ok ? 'OK' : 'BAD'}`; + console.log(line); + summary.push(ok); + } + } + console.log( + `[n13] ИТОГ mode=${mode} sessions=${withSessions}: попыток ${summary.length}, некорректных ${totalBad}`, + ); + process.exitCode = totalBad > 0 ? 1 : 0; +} finally { + await migrator.end(); + child.kill('SIGTERM'); +} diff --git a/scripts/test/node-commands.d.mts b/scripts/test/node-commands.d.mts new file mode 100644 index 0000000..2a6a98c --- /dev/null +++ b/scripts/test/node-commands.d.mts @@ -0,0 +1,9 @@ +export type Tool = 'tsx' | 'vitest' | 'prettier'; +export declare function toolEntry(tool: Tool): string; +export declare function nodeTool(tool: Tool, args?: string[]): { command: string; args: string[] }; +export declare function nodeWithTsx(args?: string[]): { command: string; args: string[] }; +export declare function baseEnv( + platform?: NodeJS.Platform, + env?: NodeJS.ProcessEnv, +): Record; +export declare const repoRoot: string; diff --git a/scripts/test/node-commands.mjs b/scripts/test/node-commands.mjs new file mode 100644 index 0000000..3998fae --- /dev/null +++ b/scripts/test/node-commands.mjs @@ -0,0 +1,76 @@ +// Запуск инструментов из node_modules без .cmd-обёрток и без npx. +// Под Windows `node_modules/.bin/tsx` и `npx` — это .cmd-файлы: spawn() без shell +// падает с ENOENT. Поэтому всегда запускаем текущий node (process.execPath) с +// JS-точкой входа пакета, найденной через require.resolve. +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const repoRoot = path.resolve(import.meta.dirname, '../..'); +const require = createRequire(path.join(repoRoot, 'package.json')); + +function packageBin(name, binName = name) { + const manifestPath = require.resolve(`${name}/package.json`); + const manifest = require(manifestPath); + const bin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[binName]; + if (!bin) throw new Error(`${name}: в package.json нет bin "${binName}"`); + return path.join(path.dirname(manifestPath), bin); +} + +/** Абсолютный путь к JS-точке входа CLI (не .cmd, не .bin). */ +export function toolEntry(tool) { + switch (tool) { + case 'tsx': + return require.resolve('tsx/cli'); + case 'vitest': + return packageBin('vitest'); + case 'prettier': + return packageBin('prettier'); + default: + throw new Error(`неизвестный инструмент: ${tool}`); + } +} + +/** Команда для spawn(command, args): всегда process.execPath + JS-файл CLI. */ +export function nodeTool(tool, args = []) { + return { command: process.execPath, args: [toolEntry(tool), ...args] }; +} + +/** + * Запуск TypeScript-файла (например, src/server.ts) в ОДНОМ процессе node с + * загрузчиком tsx (`node --import tsx`). CLI tsx запускает дочерний node, и под + * Windows kill() родителя (TerminateProcess) оставил бы сервер-сироту на порту. + */ +export function nodeWithTsx(args = []) { + const loader = pathToFileURL(require.resolve('tsx')).href; + return { command: process.execPath, args: ['--import', loader, ...args] }; +} + +/** + * Минимальное окружение дочернего процесса: PATH и (под Windows) системные + * переменные, без которых node не может работать с сетью/криптографией + * (SystemRoot, WINDIR, TEMP и т. п.). Секреты родителя не наследуются. + */ +export function baseEnv(platform = process.platform, env = process.env) { + const keys = ['PATH']; + if (platform === 'win32') { + keys.push( + 'SystemRoot', + 'WINDIR', + 'TEMP', + 'TMP', + 'USERPROFILE', + 'LOCALAPPDATA', + 'APPDATA', + 'ComSpec', + 'PATHEXT', + ); + } + const result = {}; + for (const key of keys) { + if (env[key] !== undefined) result[key] = env[key]; + } + return result; +} + +export { repoRoot }; diff --git a/scripts/test/provision-pg.mjs b/scripts/test/provision-pg.mjs new file mode 100644 index 0000000..95e79b2 --- /dev/null +++ b/scripts/test/provision-pg.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +// Тестовый стенд PostgreSQL, повторяющий роли pilot, но без суперпользователя-мигратора: +// * bootstrap-админ (E2E_PG_ADMIN_URL) используется ТОЛЬКО здесь, как DBA/entrypoint образа; +// * krona_migrator — NOSUPERUSER NOCREATEDB NOCREATEROLE, владелец БД и схемы public; +// * krona_staff_app — NOSUPERUSER NOBYPASSRLS NOINHERIT, права выдают миграции 0003+. +// Пароли генерируются crypto.randomBytes и пишутся только в env-файл с правами 0600. +// Печатаются имена ролей/БД, но не пароли. +import { randomBytes } from 'node:crypto'; +import { writeFileSync } from 'node:fs'; +import pg from 'pg'; + +const args = new Map(); +for (let i = 2; i < process.argv.length; i += 2) args.set(process.argv[i], process.argv[i + 1]); +const adminUrl = process.env.E2E_PG_ADMIN_URL; +const envFile = args.get('--out'); +const database = args.get('--db') ?? 'krona_staff_e2e'; +// --roles keep: роли уже созданы (например, предыдущим вызовом accept-branch) — пароли НЕ меняются, +// только пересоздаётся БД. Нужно, чтобы параллельные/последовательные стенды не ломали друг другу пароли. +const keepRoles = args.get('--roles') === 'keep'; +if (!adminUrl || (!envFile && !keepRoles)) { + console.error( + 'Usage: E2E_PG_ADMIN_URL=... node scripts/test/provision-pg.mjs --out [--db name]\n' + + ' E2E_PG_ADMIN_URL=... node scripts/test/provision-pg.mjs --roles keep --db name', + ); + process.exit(2); +} +if (!/^[a-z_][a-z0-9_]{0,62}$/.test(database)) throw new Error('Invalid database name'); + +export async function provision() { + const migratorPassword = randomBytes(24).toString('hex'); + const appPassword = randomBytes(24).toString('hex'); + const admin = new pg.Client({ + connectionString: adminUrl, + application_name: 'krona-e2e-provision', + }); + await admin.connect(); + try { + if (keepRoles) { + const existing = await admin.query( + `SELECT count(*)::int AS n FROM pg_roles WHERE rolname IN ('krona_migrator','krona_staff_app')`, + ); + if (existing.rows[0].n !== 2) + throw new Error('--roles keep: роли krona_migrator/krona_staff_app не созданы'); + } else { + await admin.query(` + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'krona_migrator') THEN + CREATE ROLE krona_migrator LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'krona_staff_app') THEN + CREATE ROLE krona_staff_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS; + END IF; + END $$`); + const alterMigrator = await admin.query( + "SELECT format('ALTER ROLE krona_migrator WITH LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS', $1::text) AS sql", + [migratorPassword], + ); + await admin.query(alterMigrator.rows[0].sql); + const alterApp = await admin.query( + "SELECT format('ALTER ROLE krona_staff_app WITH LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS', $1::text) AS sql", + [appPassword], + ); + await admin.query(alterApp.rows[0].sql); + } + await admin.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database], + ); + await admin.query(`DROP DATABASE IF EXISTS ${database}`); + await admin.query( + `CREATE DATABASE ${database} OWNER krona_migrator ENCODING 'UTF8' TEMPLATE template0`, + ); + } finally { + await admin.end(); + } + const dbAdminUrl = new URL(adminUrl); + dbAdminUrl.pathname = `/${database}`; + const dbAdmin = new pg.Client({ connectionString: dbAdminUrl.toString() }); + await dbAdmin.connect(); + try { + await dbAdmin.query('ALTER SCHEMA public OWNER TO krona_migrator'); + await dbAdmin.query('REVOKE CREATE ON SCHEMA public FROM PUBLIC'); + await dbAdmin.query(`REVOKE ALL ON DATABASE ${database} FROM PUBLIC`); + await dbAdmin.query(`GRANT CONNECT ON DATABASE ${database} TO krona_staff_app`); + } finally { + await dbAdmin.end(); + } + if (keepRoles) { + console.log(`recreated database=${database} (roles kept, passwords unchanged)`); + return; + } + const base = new URL(adminUrl); + const url = (user, password) => { + const u = new URL(base.toString()); + u.username = user; + u.password = password; + u.pathname = `/${database}`; + return u.toString(); + }; + const migratorUrl = url('krona_migrator', migratorPassword); + const appUrl = url('krona_staff_app', appPassword); + const lines = [ + `DATABASE_MIGRATION_URL=${migratorUrl}`, + `DATABASE_TEST_MIGRATION_URL=${migratorUrl}`, + `DATABASE_TEST_URL=${appUrl}`, + `DATABASE_URL=${appUrl}`, + `POSTGRES_APP_PASSWORD=${appPassword}`, + '', + ]; + writeFileSync(envFile, lines.join('\n'), { mode: 0o600 }); + // Проверяем атрибуты ролей и печатаем их (без паролей) как доказательство. + const check = new pg.Client({ connectionString: migratorUrl }); + await check.connect(); + try { + const roles = await check.query( + `SELECT rolname, rolsuper, rolcreaterole, rolcreatedb, rolbypassrls + FROM pg_roles WHERE rolname IN ('krona_migrator','krona_staff_app') ORDER BY rolname`, + ); + const version = await check.query('SHOW server_version'); + console.log( + `provisioned database=${database} server_version=${version.rows[0].server_version}`, + ); + for (const r of roles.rows) { + console.log( + `role=${r.rolname} super=${r.rolsuper} createrole=${r.rolcreaterole} createdb=${r.rolcreatedb} bypassrls=${r.rolbypassrls}`, + ); + } + } finally { + await check.end(); + } +} + +await provision(); diff --git a/scripts/test/run-e2e.mjs b/scripts/test/run-e2e.mjs new file mode 100644 index 0000000..d202643 --- /dev/null +++ b/scripts/test/run-e2e.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +// Запуск e2e с «храповиком» известных дефектов. +// * Сценарии проверяют ПРАВИЛЬНОЕ поведение, поэтому открытые дефекты дают красные тесты. +// * tests/e2e/known-defects.json — список ID, красный цвет которых сейчас ожидаем. +// * exit 0, если: все [HARNESS]-тесты зелёные И каждый красный тест есть в списке +// И ни один ID из списка не стал полностью зелёным (исправленный дефект нужно +// убрать из списка — иначе регрессию потом никто не заметит). +// * --no-known-defects: строгий режим, exit = код vitest (все дефекты красные). +// Полный вывод пишется в tests/e2e/evidence/e2e-run.txt, таблица — в e2e-table.md. +// tests/e2e/evidence/ не хранится в git (.gitignore): в CI это артефакт задачи e2e. +import { spawn } from 'node:child_process'; +import { createWriteStream, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { nodeTool } from './node-commands.mjs'; + +const repoRoot = path.resolve(import.meta.dirname, '../..'); +const evidence = path.join(repoRoot, 'tests/e2e/evidence'); +mkdirSync(evidence, { recursive: true }); +const strict = process.argv.includes('--no-known-defects'); +const jsonPath = path.join(evidence, 'e2e-results.json'); +const logPath = path.join(evidence, 'e2e-run.txt'); +const extra = process.argv.slice(2).filter((arg) => !arg.startsWith('--no-known-defects')); + +const vitestArgs = [ + 'run', + '--config', + 'tests/e2e/vitest.e2e.config.ts', + '--reporter=verbose', + '--reporter=json', + `--outputFile.json=${jsonPath}`, + ...extra, +]; +const log = createWriteStream(logPath); +const vitest = nodeTool('vitest', vitestArgs); +log.write(`$ node ${vitestArgs.join(' ')}\n`); +const exitCode = await new Promise((resolve) => { + const child = spawn(vitest.command, vitest.args, { cwd: repoRoot, env: process.env }); + for (const stream of [child.stdout, child.stderr]) { + stream.on('data', (chunk) => { + process.stdout.write(chunk); + log.write(chunk); + }); + } + child.on('close', (code) => resolve(code ?? 1)); +}); +log.end(); + +let results; +try { + results = JSON.parse(readFileSync(jsonPath, 'utf8')); +} catch { + console.error('[run-e2e] нет JSON-результатов: стенд не поднялся (см. e2e-run.txt)'); + process.exit(1); +} +const known = strict + ? {} + : JSON.parse(readFileSync(path.join(repoRoot, 'tests/e2e/known-defects.json'), 'utf8')).defects; + +const byId = new Map(); +for (const file of results.testResults) { + for (const test of file.assertionResults) { + const id = /^\[([A-Z0-9/-]+)\]/.exec(test.title)?.[1] ?? 'UNTAGGED'; + const entry = byId.get(id) ?? { passed: 0, failed: 0, tests: [] }; + if (test.status === 'passed') entry.passed += 1; + else if (test.status === 'failed') entry.failed += 1; + entry.tests.push({ title: test.title, status: test.status }); + byId.set(id, entry); + } +} + +const problems = []; +// Страховка: в evidence не должно попасть ничего похожего на секреты стенда +// (hex-токены 48/64 символа, токены устройств kstaff_dev_*, URL с паролем). +const secretPatterns = [ + /\b[0-9a-f]{48}\b/, + /\b[0-9a-f]{64}\b/, + /kstaff_dev_[0-9A-Z]{26}_[0-9a-f]{16,}/, + /postgres:\/\/[^\s:@]+:[^\s@]+@/, +]; +// Только файлы верхнего уровня: в подкаталогах (accept/ от accept-branch.mjs) свои логи с редакцией. +for (const entry of readdirSync(evidence, { withFileTypes: true })) { + if (!entry.isFile()) continue; + const name = entry.name; + const content = readFileSync(path.join(evidence, name), 'utf8'); + for (const pattern of secretPatterns) { + if (pattern.test(content)) + problems.push(`evidence/${name}: найдено похожее на секрет (${pattern})`); + } +} +const rows = []; +for (const [id, entry] of [...byId.entries()].sort()) { + const red = entry.failed > 0; + let verdict; + if (id === 'HARNESS' || id === 'UNTAGGED') { + verdict = red ? 'СТЕНД СЛОМАН' : 'зелёный (позитивный контроль)'; + if (red || id === 'UNTAGGED') problems.push(`${id}: ${red ? 'красный' : 'тест без ID'}`); + } else if (red && known[id]) verdict = 'красный (известный дефект)'; + else if (red) { + verdict = 'красный (НЕ в known-defects)'; + problems.push(`${id}: новый красный тест — регрессия или новый дефект`); + } else if (known[id]) { + verdict = 'зелёный — ИСПРАВЛЕН, уберите из known-defects.json'; + problems.push(`${id}: дефект исправлен, обновите known-defects.json`); + } else verdict = 'зелёный'; + rows.push( + `| ${id} | ${red ? 'RED' : 'GREEN'} | ${entry.passed}/${entry.passed + entry.failed} | ${verdict} |`, + ); +} +for (const id of Object.keys(known)) { + if (!byId.has(id)) problems.push(`${id}: есть в known-defects.json, но сценария нет`); +} +const table = ['| ID | Цвет | Зелёных/всего | Вердикт |', '|---|---|---|---|', ...rows].join('\n'); +writeFileSync(path.join(evidence, 'e2e-table.md'), `${table}\n`); +console.log(`\n[run-e2e] vitest exit=${exitCode}\n${table}`); + +if (strict) { + if (problems.some((p) => p.startsWith('evidence/'))) { + console.error(`\n[run-e2e] FAIL:\n- ${problems.join('\n- ')}`); + process.exit(1); + } + process.exit(exitCode); +} +if (problems.length > 0) { + console.error(`\n[run-e2e] FAIL:\n- ${problems.join('\n- ')}`); + process.exit(1); +} +console.log('\n[run-e2e] OK: стенд работает, все красные сценарии — известные открытые дефекты'); diff --git a/tests/e2e/00-harness.e2e.ts b/tests/e2e/00-harness.e2e.ts new file mode 100644 index 0000000..b4fd772 --- /dev/null +++ b/tests/e2e/00-harness.e2e.ts @@ -0,0 +1,119 @@ +// [HARNESS] Позитивный контроль стенда: эти тесты обязаны быть зелёными всегда. +// Если они красные — сломан сам стенд, и красные дефектные сценарии ничего не доказывают. +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import pg from 'pg'; +import { describe, expect, it } from 'vitest'; +import { api, ctx, describeResponse } from './harness/client.js'; + +describe('HARNESS: реальный сервер + PostgreSQL 17', () => { + it('[HARNESS] сервер отвечает live/ready (ready проверяет БД)', async () => { + const live = await api('GET', '/live'); + expect(live.status).toBe(200); + const ready = await api('GET', '/ready'); + expect(ready.status, describeResponse(ready)).toBe(200); + expect(ready.body).toEqual({ status: 'ready' }); + }); + + it('[HARNESS] БД: PostgreSQL 17, приложение под ролью без SUPERUSER/BYPASSRLS, мигратор без SUPERUSER', async () => { + const client = new pg.Client({ connectionString: ctx().appDatabaseUrl }); + await client.connect(); + try { + const version = await client.query<{ server_version_num: string }>('SHOW server_version_num'); + expect(Number(version.rows[0]!.server_version_num)).toBeGreaterThanOrEqual(170000); + const roles = await client.query<{ + rolname: string; + rolsuper: boolean; + rolbypassrls: boolean; + rolcreaterole: boolean; + }>( + `SELECT rolname, rolsuper, rolbypassrls, rolcreaterole FROM pg_roles + WHERE rolname IN (current_user, 'krona_migrator') ORDER BY rolname`, + ); + expect(roles.rows).toEqual([ + { rolname: 'krona_migrator', rolsuper: false, rolbypassrls: false, rolcreaterole: false }, + { rolname: 'krona_staff_app', rolsuper: false, rolbypassrls: false, rolcreaterole: false }, + ]); + } finally { + await client.end(); + } + }); + + it('[HARNESS] миграции применены мигратором, app-роль не видит schema_migrations', async () => { + const migrator = new pg.Client({ connectionString: ctx().migrationDatabaseUrl }); + await migrator.connect(); + try { + const applied = await migrator.query<{ count: string }>( + 'SELECT count(*)::text AS count FROM schema_migrations', + ); + expect(Number(applied.rows[0]!.count)).toBeGreaterThanOrEqual(11); + } finally { + await migrator.end(); + } + const app = new pg.Client({ connectionString: ctx().appDatabaseUrl }); + await app.connect(); + try { + await expect(app.query('SELECT 1 FROM schema_migrations')).rejects.toThrow( + /permission denied/, + ); + } finally { + await app.end(); + } + }); + + it('[HARNESS] токены: без токена 401, случайный токен 401, manager-токен 200', async () => { + expect((await api('GET', '/portal')).status).toBe(401); + expect((await api('GET', '/portal', { token: 'x'.repeat(64) })).status).toBe(401); + const ok = await api('GET', '/portal', { token: ctx().managerToken }); + expect(ok.status, describeResponse(ok)).toBe(200); + expect(ok.body.viewer.role).toBe('manager'); + }); + + it('[HARNESS] терминал: отметка сотрудника из импорта принимается (201)', async () => { + const { importEmployees, clock, daysAgo } = await import('./harness/fixtures.js'); + const [employee] = await importEmployees(1); + const response = await clock(employee!.id, 'check_in', daysAgo(1)); + expect(response.status, describeResponse(response)).toBe(201); + }); + + // [CTRL] Позитивные контроли безопасности, которые аудит признал рабочими: они + // должны оставаться зелёными и доказывают, что сценарии умеют быть зелёными. + it('[CTRL] RBAC: терминал не видит портал и не создаёт сотрудников; сотрудник видит только себя', async () => { + const { importEmployees, issueEmployeeToken } = await import('./harness/fixtures.js'); + const [self, other] = await importEmployees(2); + const employeeToken = await issueEmployeeToken(self!.id); + const terminalPortal = await api('GET', '/portal', { token: ctx().terminalToken }); + const terminalCreate = await api('POST', '/employees', { + token: ctx().terminalToken, + body: { personnel_number: 'E2E-CTRL', display_name: 'E2E-CTRL' }, + }); + const own = await api('GET', '/portal', { token: employeeToken }); + const rows = (own.body?.employees ?? []) as Array<{ id: string }>; + const ids = rows.map((row) => row.id); + expect({ + terminalPortal: terminalPortal.status, + terminalCreate: terminalCreate.status, + own: own.status, + seesSelf: ids.includes(self!.id), + seesOther: ids.includes(other!.id), + }).toEqual({ + terminalPortal: 403, + terminalCreate: 403, + own: 200, + seesSelf: true, + seesOther: false, + }); + }); + + it('[CTRL] уволенный сотрудник теряет доступ (401)', async () => { + const { importEmployees, issueEmployeeToken } = await import('./harness/fixtures.js'); + const [employee] = await importEmployees(1); + const employeeToken = await issueEmployeeToken(employee!.id); + expect((await api('GET', '/portal', { token: employeeToken })).status).toBe(200); + const dismiss = await api('PATCH', `/employees/${employee!.id}`, { + token: ctx().managerToken, + body: { status: 'dismissed' }, + }); + expect(dismiss.status, describeResponse(dismiss)).toBe(200); + expect((await api('GET', '/portal', { token: employeeToken })).status).toBe(401); + }); +}); diff --git a/tests/e2e/10-core-defects.e2e.ts b/tests/e2e/10-core-defects.e2e.ts new file mode 100644 index 0000000..f01cb91 --- /dev/null +++ b/tests/e2e/10-core-defects.e2e.ts @@ -0,0 +1,326 @@ +// Сценарии дефектов основного API (ID из аудита Grok 2026-09-26; сопоставление с ID +// владельца — в REPORT.md). Каждый тест проверяет ПРАВИЛЬНОЕ поведение, поэтому +// красный тест = открытый дефект. +/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ +import { createECDH, randomBytes } from 'node:crypto'; +import { createServer, type Server } from 'node:net'; +import { describe, expect, it } from 'vitest'; +import { api, ctx, dbQuery, describeResponse } from './harness/client.js'; +import { randomUlid, synthetic } from './harness/context.js'; +import { + SYNTHETIC_RATE_MINOR, + assignHourly, + clock, + createOrgUnits, + daysAgo, + hourlyRuleId, + importEmployees, + isoDate, + issueEmployeeToken, + workedShift, +} from './harness/fixtures.js'; + +function pushKeys() { + const ecdh = createECDH('prime256v1'); + ecdh.generateKeys(); + return { + p256dh: ecdh.getPublicKey().toString('base64url'), + auth: randomBytes(16).toString('base64url'), + }; +} + +function monthOf(date: Date): string { + return date.toISOString().slice(0, 7); +} + +async function accrualsFor(employeeId: string) { + return dbQuery<{ amount_minor: string; ref_id: string }>( + `SELECT amount_minor::text AS amount_minor, ref_id FROM staff_payroll_ledger + WHERE employee_id = $1 AND kind = 'accrual'`, + [employeeId], + ); +} + +describe('Основной API на реальной PostgreSQL', () => { + it('[K4/N1] менеджер создаёт сотрудника через POST /employees → 201', async () => { + const number = `E2E-${randomUlid().slice(-8)}`; + const response = await api('POST', '/employees', { + token: ctx().managerToken, + body: { + personnel_number: number, + display_name: synthetic('Сотрудник'), + position: 'E2E-должность', + }, + }); + expect(response.status, describeResponse(response)).toBe(201); + const rows = await dbQuery('SELECT id FROM staff_employees WHERE personnel_number = $1', [ + number, + ]); + expect(rows).toHaveLength(1); + }); + + it('[K3/N2] подтверждение смены, расчёт и утверждение зарплаты проходят (200) и начисляют 8 ч × ставка', async () => { + const [unit] = await createOrgUnits(1); + const [employee] = await importEmployees(1); + await assignHourly(employee!.id, unit!.id); + const day = daysAgo(3); + const sessionId = await workedShift(employee!.id, unit!.id, day, 8); + + const confirm = await api('PATCH', `/work-sessions/${sessionId}`, { + token: ctx().managerToken, + body: { status: 'confirmed' }, + }); + expect(confirm.status, `подтверждение смены: ${describeResponse(confirm)}`).toBe(200); + + const calculate = await api('POST', '/payroll/calculate', { + token: ctx().managerToken, + body: { month: monthOf(day), org_unit_id: unit!.id }, + }); + expect(calculate.status, `расчёт: ${describeResponse(calculate)}`).toBe(200); + const runId = (calculate.body.run_id ?? calculate.body.id ?? calculate.body.run?.id) as string; + const approve = await api('POST', `/payroll/runs/${runId}/approve`, { + token: ctx().managerToken, + }); + expect(approve.status, `утверждение: ${describeResponse(approve)}`).toBe(200); + + const accruals = await accrualsFor(employee!.id); + const total = accruals.reduce((sum, row) => sum + BigInt(row.amount_minor), 0n); + expect(total).toBe(BigInt(8 * SYNTHETIC_RATE_MINOR)); + }); + + it('[K5/N4] пересекающиеся расчёты не начисляют одну смену дважды', async () => { + const [unit] = await createOrgUnits(1); + const [employee] = await importEmployees(1); + await assignHourly(employee!.id, unit!.id); + const day = daysAgo(2); + const sessionId = await workedShift(employee!.id, unit!.id, day, 8); + const confirm = await api('PATCH', `/work-sessions/${sessionId}`, { + token: ctx().managerToken, + body: { status: 'confirmed' }, + }); + expect( + confirm.status, + `подтверждение смены (предусловие, см. N2): ${describeResponse(confirm)}`, + ).toBe(200); + + const month = monthOf(day); + const first = await api('POST', '/payroll/calculate', { + token: ctx().managerToken, + body: { month, org_unit_id: unit!.id }, + }); + expect(first.status, `первый расчёт (предусловие, см. N2): ${describeResponse(first)}`).toBe( + 200, + ); + const firstRun = (first.body.run_id ?? first.body.id) as string; + expect( + (await api('POST', `/payroll/runs/${firstRun}/approve`, { token: ctx().managerToken })) + .status, + ).toBe(200); + + const lastDay = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth() + 1, 0)); + const secondFrom = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), 2)); + const second = await api('POST', '/payroll/calculate', { + token: ctx().managerToken, + body: { from: isoDate(secondFrom), to: isoDate(lastDay), org_unit_id: unit!.id }, + }); + if (second.status === 200) { + const secondRun = (second.body.run_id ?? second.body.id) as string; + await api('POST', `/payroll/runs/${secondRun}/approve`, { token: ctx().managerToken }); + } else { + expect(second.status, describeResponse(second)).toBe(409); + } + const accruals = await accrualsFor(employee!.id); + const total = accruals.reduce((sum, row) => sum + BigInt(row.amount_minor), 0n); + expect(total, 'смена должна быть начислена ровно один раз').toBe( + BigInt(8 * SYNTHETIC_RATE_MINOR), + ); + }); + + it('[K12/N3] push-подписка на внутренний адрес отклоняется, сервер не подключается к 127.0.0.1', async () => { + const [employee] = await importEmployees(1); + const employeeToken = await issueEmployeeToken(employee!.id); + let connections = 0; + const listener: Server = createServer((socket) => { + connections += 1; + socket.destroy(); + }); + await new Promise((resolve) => listener.listen(0, '127.0.0.1', resolve)); + const address = listener.address(); + const port = typeof address === 'object' && address ? address.port : 0; + try { + const subscribe = await api('POST', '/push/subscriptions', { + token: employeeToken, + body: { endpoint: `https://127.0.0.1:${port}/e2e-internal-admin`, keys: pushKeys() }, + }); + // Инициируем отправку push этому сотруднику (отметка терминала) и ждём попытку соединения. + await clock(employee!.id, 'check_in', daysAgo(0, 0)); + await new Promise((resolve) => setTimeout(resolve, 2_500)); + expect( + { status: subscribe.status, connections }, + `подписка: ${describeResponse(subscribe)}`, + ).toEqual({ + status: expect.toSatisfy((s: number) => s === 400 || s === 422 || s === 403), + connections: 0, + }); + } finally { + listener.close(); + } + }); + + it('[N9] чужой push-endpoint нельзя перепривязать к другому сотруднику', async () => { + const [employeeA, employeeB] = await importEmployees(2); + const tokenA = await issueEmployeeToken(employeeA!.id); + const tokenB = await issueEmployeeToken(employeeB!.id); + const endpoint = `https://fcm.googleapis.com/fcm/send/e2e-${randomBytes(16).toString('hex')}`; + const first = await api('POST', '/push/subscriptions', { + token: tokenA, + body: { endpoint, keys: pushKeys() }, + }); + expect(first.status, describeResponse(first)).toBe(201); + const second = await api('POST', '/push/subscriptions', { + token: tokenB, + body: { endpoint, keys: pushKeys() }, + }); + const owner = await dbQuery<{ employee_id: string }>( + 'SELECT employee_id FROM staff_push_subscriptions WHERE endpoint = $1', + [endpoint], + ); + expect( + { status: second.status, owner: owner.map((row) => row.employee_id) }, + describeResponse(second), + ).toEqual({ + status: expect.toSatisfy((s: number) => s === 409 || s === 403), + owner: [employeeA!.id], + }); + }); + + it('[N12] PUT pay-assignment отвергает лишние поля и нереальные суммы (400)', async () => { + const [employee] = await importEmployees(1); + const payRuleId = await hourlyRuleId(); + const extra = await api('PUT', `/employees/${employee!.id}/pay-assignment`, { + token: ctx().managerToken, + body: { pay_rule_id: payRuleId, rate_minor: SYNTHETIC_RATE_MINOR, e2e_unexpected_field: 'x' }, + }); + const huge = await api('PUT', `/employees/${employee!.id}/pay-assignment`, { + token: ctx().managerToken, + body: { + pay_rule_id: payRuleId, + rate_minor: Number.MAX_SAFE_INTEGER, + guarantee_minor: Number.MAX_SAFE_INTEGER, + }, + }); + expect( + { extra: extra.status, huge: huge.status }, + `${describeResponse(extra)} | ${describeResponse(huge)}`, + ).toEqual({ + extra: 400, + huge: 400, + }); + }); + + it('[N10] терминальный токен не читает справочники профессий и точек (403)', async () => { + const professions = await api('GET', '/professions', { token: ctx().terminalToken }); + const units = await api('GET', '/org-units', { token: ctx().terminalToken }); + expect({ professions: professions.status, units: units.status }).toEqual({ + professions: 403, + units: 403, + }); + }); + + it('[K1/F2] standalone без токенов не выдаёт права менеджера анонимному запросу (401)', async () => { + const portal = await api('GET', '/portal', { base: 'open' }); + const payroll = await api('GET', '/payroll', { base: 'open' }); + const write = await api('POST', '/professions', { + base: 'open', + body: { name: synthetic('Профессия') }, + }); + expect({ portal: portal.status, payroll: payroll.status, write: write.status }).toEqual({ + portal: 401, + payroll: 401, + write: 401, + }); + }); + + it('[K10/F3] cookie сессии не содержит сырой токен и отзывается при выходе', async () => { + const login = await api('POST', '/session', { body: { access_token: ctx().managerToken } }); + expect(login.status, describeResponse(login)).toBe(200); + const setCookie = login.headers.get('set-cookie') ?? ''; + const value = decodeURIComponent( + /__Host-krona_staff_session=([^;]*)/.exec(setCookie)?.[1] ?? '', + ); + const cookie = `__Host-krona_staff_session=${encodeURIComponent(value)}`; + const logout = await api('DELETE', '/session', { headers: { cookie } }); + expect(logout.status).toBe(204); + const reuse = await api('GET', '/portal', { headers: { cookie } }); + expect({ rawTokenInCookie: value === ctx().managerToken, afterLogout: reuse.status }).toEqual({ + rawTokenInCookie: false, + afterLogout: 401, + }); + }); + + it('[F10] мутация по cookie без Origin отклоняется (CSRF, 403)', async () => { + const login = await api('POST', '/session', { body: { access_token: ctx().managerToken } }); + const setCookie = login.headers.get('set-cookie') ?? ''; + const cookie = /(__Host-krona_staff_session=[^;]*)/.exec(setCookie)?.[1] ?? ''; + const response = await api('POST', '/professions', { + headers: { cookie }, + body: { name: synthetic('CSRF-профессия') }, + }); + expect(response.status, describeResponse(response)).toBe(403); + }); + + it('[F7] повтор Idempotency-Key с другим телом не создаёт второе событие', async () => { + const [employee] = await importEmployees(1); + const key = `e2e-${randomUlid()}`; + const send = (sourceEventId: string) => + api('POST', '/attendance/events', { + token: ctx().terminalToken, + headers: { 'idempotency-key': key }, + body: { + employee_id: employee!.id, + event_type: 'check_in', + occurred_at: daysAgo(1).toISOString(), + source_event_id: sourceEventId, + }, + }); + const first = await send(`e2e-${randomUlid()}`); + const second = await send(`e2e-${randomUlid()}`); + expect({ first: first.status, secondCreated: second.status === 201 }).toEqual({ + first: 201, + secondCreated: false, + }); + }); + + it('[N11] импорт с неверным kind начисления → 400, а не 500', async () => { + const [employee] = await importEmployees(1); + const response = await api('POST', '/imports/owner-workbook', { + token: ctx().managerToken, + body: { + source_name: synthetic('import-bad-kind'), + payroll_entries: [ + { + external_key: `e2e-${randomUlid()}`, + employee_external_ref: employee!.external_ref, + business_date: isoDate(daysAgo(1)), + kind: 'e2e_bogus_kind', + amount_minor: 100, + }, + ], + }, + }); + expect(response.status, describeResponse(response)).toBe(400); + }); + + it('[K11/F4] перебор ключей входа ограничивается (429 не позже 30 неверных попыток)', async () => { + const statuses: number[] = []; + for (let attempt = 0; attempt < 30; attempt += 1) { + const response = await api('POST', '/session', { + base: 'ratelimit', + body: { access_token: randomBytes(32).toString('hex') }, + }); + statuses.push(response.status); + if (response.status === 429) break; + } + expect(statuses.includes(429), `статусы: ${[...new Set(statuses)].join(',')}`).toBe(true); + }); +}); diff --git a/tests/e2e/20-sync-defects.e2e.ts b/tests/e2e/20-sync-defects.e2e.ts new file mode 100644 index 0000000..14e10d8 --- /dev/null +++ b/tests/e2e/20-sync-defects.e2e.ts @@ -0,0 +1,287 @@ +// Сценарии офлайн-синхронизации (/api/v1/staff/sync/*, миграция 0011). +// Проверяют ПРАВИЛЬНОЕ поведение: красный тест = открытый дефект. +/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { api, ctx, dbQuery, describeResponse } from './harness/client.js'; +import { randomUlid, synthetic } from './harness/context.js'; +import { createOrgUnits, daysAgo, importEmployees, isoDate } from './harness/fixtures.js'; + +interface Device { + id: string; + token: string; +} + +async function registerDevice(orgUnitId?: string): Promise { + const response = await api('POST', '/sync/devices', { + token: ctx().managerToken, + body: { + device_code: `E2E-${randomUlid().slice(-10)}`, + name: synthetic('Терминал'), + ...(orgUnitId ? { org_unit_id: orgUnitId } : {}), + }, + }); + expect(response.status, describeResponse(response)).toBe(201); + return { id: response.body.device.id as string, token: response.body.token as string }; +} + +function command(commandType: string, payload: Record) { + return { + command_type: commandType, + schema_version: 1, + idempotency_key: `e2e-${randomUlid()}`, + correlation_id: randomUlid(), + created_at: new Date().toISOString(), + payload, + }; +} + +async function push(device: Device, commands: unknown[]) { + return api('POST', '/sync/push', { token: device.token, body: { commands } }); +} + +async function pullAll(device: Device, cursor: string | null = null) { + const changes: Array<{ entity_type: string; entity_id: string }> = []; + let next: string | null = cursor; + for (let page = 0; page < 50; page += 1) { + const response = await api('POST', '/sync/pull', { + token: device.token, + body: { cursor: next, limit: 100 }, + }); + expect(response.status, describeResponse(response)).toBe(200); + changes.push(...(response.body.changes as Array<{ entity_type: string; entity_id: string }>)); + next = response.body.next_cursor as string; + if (!response.body.has_more) break; + } + return { changes, cursor: next }; +} + +describe('Офлайн-синхронизация на реальной PostgreSQL', () => { + it('[K13/SYNC-A] устройство не может само подтвердить смену (status=confirmed отклоняется)', async () => { + const [unit] = await createOrgUnits(1); + const [employee] = await importEmployees(1); + const device = await registerDevice(unit!.id); + const sessionId = randomUlid(); + const response = await push(device, [ + command('upsert_work_session', { + id: sessionId, + employee_id: employee!.id, + org_unit_id: unit!.id, + business_date: isoDate(daysAgo(1)), + worked_minutes: 600, + status: 'confirmed', + }), + ]); + const stored = await dbQuery<{ status: string }>( + 'SELECT status FROM staff_work_sessions WHERE id = $1', + [sessionId], + ); + expect( + { + http: response.status, + result: response.body?.results?.[0]?.status, + stored: stored[0]?.status ?? null, + }, + describeResponse(response), + ).toEqual({ http: 200, result: 'rejected', stored: null }); + }); + + it('[K13/SYNC-B] устройство не может утвердить табель (status=approved отклоняется)', async () => { + const [employee] = await importEmployees(1); + const device = await registerDevice(); + const entryId = randomUlid(); + const response = await push(device, [ + command('upsert_timesheet_entry', { + id: entryId, + employee_id: employee!.id, + work_date: isoDate(daysAgo(1)), + minutes_worked: 600, + status: 'approved', + }), + ]); + const stored = await dbQuery<{ status: string }>( + 'SELECT status FROM staff_timesheet_entries WHERE id = $1', + [entryId], + ); + expect( + { result: response.body?.results?.[0]?.status, stored: stored[0]?.status ?? null }, + describeResponse(response), + ).toEqual({ result: 'rejected', stored: null }); + }); + + it('[SYNC-C0] начальная загрузка (pull без курсора) работает на реальной БД (200)', async () => { + await createOrgUnits(1); + await importEmployees(1); + const device = await registerDevice(); + const response = await api('POST', '/sync/pull', { + token: device.token, + body: { cursor: null }, + }); + expect(response.status, describeResponse(response)).toBe(200); + const types = new Set( + (response.body.changes as Array<{ entity_type: string }>).map((c) => c.entity_type), + ); + expect(types.has('employee') && types.has('org_unit')).toBe(true); + }); + + it('[S5/SYNC-C] начальная загрузка >100 сущностей отдаёт все сущности постранично', async () => { + const employees = await importEmployees(120); + const device = await registerDevice(); + const { changes } = await pullAll(device, null); + const received = new Set( + changes.filter((c) => c.entity_type === 'employee').map((c) => c.entity_id), + ); + const missing = employees.filter((employee) => !received.has(employee.id)).length; + expect(missing, `не доставлено сотрудников: ${missing} из ${employees.length}`).toBe(0); + }); + + it('[S5/SYNC-D] изменения, сделанные через портал, попадают в инкрементальный pull', async () => { + const device = await registerDevice(); + // Курсор берём из /sync/status, чтобы сценарий не зависел от начальной загрузки (SYNC-C0). + const status = await api('GET', '/sync/status', { token: device.token }); + expect(status.status, describeResponse(status)).toBe(200); + const cursor = status.body.latest_cursor as string; + const [employee] = await importEmployees(1); + const patch = await api('PATCH', `/employees/${employee!.id}`, { + token: ctx().managerToken, + body: { display_name: synthetic('Переименован') }, + }); + expect(patch.status, describeResponse(patch)).toBe(200); + const { changes } = await pullAll(device, cursor); + expect(changes.some((c) => c.entity_type === 'employee' && c.entity_id === employee!.id)).toBe( + true, + ); + }); + + it('[S4/SYNC-E] отметка уволенного сотрудника через sync отклоняется', async () => { + const [employee] = await importEmployees(1); + const dismiss = await api('PATCH', `/employees/${employee!.id}`, { + token: ctx().managerToken, + body: { status: 'dismissed' }, + }); + expect(dismiss.status, describeResponse(dismiss)).toBe(200); + const device = await registerDevice(); + const response = await push(device, [ + command('attendance_event', { + employee_id: employee!.id, + event_type: 'check_in', + occurred_at: daysAgo(0, 0).toISOString(), + }), + ]); + expect(response.body?.results?.[0]?.status, describeResponse(response)).toBe('rejected'); + }); + + it('[S4/SYNC-F] отметка через sync сохраняет точку устройства; чужая точка в payload отклоняется', async () => { + const [unitA, unitB] = await createOrgUnits(2); + const [employee] = await importEmployees(1); + const device = await registerDevice(unitA!.id); + const own = command('attendance_event', { + employee_id: employee!.id, + event_type: 'check_in', + occurred_at: daysAgo(0, 0).toISOString(), + }); + const foreign = command('attendance_event', { + employee_id: employee!.id, + event_type: 'check_out', + occurred_at: new Date(daysAgo(0, 0).getTime() + 60_000).toISOString(), + org_unit_id: unitB!.id, + }); + const response = await push(device, [own, foreign]); + expect(response.status, describeResponse(response)).toBe(200); + const ownEventId = response.body.results[0]?.result?.event_id as string | undefined; + const stored = await dbQuery<{ org_unit_id: string | null }>( + 'SELECT org_unit_id FROM staff_attendance_events WHERE id = $1', + [ownEventId ?? ''], + ); + expect( + { + ownOrgUnit: stored[0]?.org_unit_id ?? null, + foreign: response.body.results[1]?.status, + }, + describeResponse(response), + ).toEqual({ ownOrgUnit: unitA!.id, foreign: 'rejected' }); + }); + + it('[S3/SYNC-G] битая команда в пакете отклоняется по отдельности, остальные применяются (не 500)', async () => { + const [employee] = await importEmployees(1); + const device = await registerDevice(); + const good = command('attendance_event', { + employee_id: employee!.id, + event_type: 'check_in', + occurred_at: daysAgo(0, 0).toISOString(), + }); + const bad = command('attendance_event', { + employee_id: employee!.id, + event_type: 'check_out', + occurred_at: 'e2e-not-a-timestamp', + }); + const response = await push(device, [good, bad]); + expect( + { + http: response.status, + statuses: (response.body?.results ?? []).map((r: { status: string }) => r.status), + }, + describeResponse(response), + ).toEqual({ http: 200, statuses: ['applied', 'rejected'] }); + }); + + it('[S8/SYNC-H] регистрация устройства по cookie с чужим Origin отклоняется (CSRF, 403)', async () => { + const login = await api('POST', '/session', { body: { access_token: ctx().managerToken } }); + const cookie = + /(__Host-krona_staff_session=[^;]*)/.exec(login.headers.get('set-cookie') ?? '')?.[1] ?? ''; + const response = await api('POST', '/sync/devices', { + headers: { cookie, origin: 'https://attacker.e2e.invalid' }, + body: { device_code: `E2E-${randomUlid().slice(-10)}`, name: synthetic('CSRF-терминал') }, + }); + expect(response.status, describeResponse(response)).toBe(403); + }); + + it('[K1/SYNC-I] без настроенных токенов анонимный запрос не регистрирует устройство (401)', async () => { + const response = await api('POST', '/sync/devices', { + base: 'open', + body: { + device_code: `E2E-${randomUlid().slice(-10)}`, + name: synthetic('Анонимный терминал'), + }, + }); + expect(response.status, describeResponse(response)).toBe(401); + }); + + it('[S2/SYNC-J] отметка через sync вне окна времени (2001 год) отклоняется, как в POST /attendance/events', async () => { + const [employee] = await importEmployees(1); + const device = await registerDevice(); + const response = await push(device, [ + command('attendance_event', { + employee_id: employee!.id, + event_type: 'check_in', + occurred_at: '2001-01-01T09:00:00.000Z', + }), + ]); + expect(response.body?.results?.[0]?.status, describeResponse(response)).toBe('rejected'); + }); + + it('[SYNC-K] ack курсора из будущего (больше выданного сервером) отклоняется (400)', async () => { + const device = await registerDevice(); + const tenant = ctx().tenantId; + const seq = '999999999'; + const hash = createHash('sha256') + .update(`${tenant}:${seq}:krona_staff_sync_cursor_salt_v1`) + .digest('hex') + .slice(0, 12); + const response = await api('POST', '/sync/ack', { + token: device.token, + body: { cursor: `cur_${seq}_${hash}` }, + }); + expect(response.status, describeResponse(response)).toBe(400); + }); + + it('[SYNC-L] отозванное устройство не может синхронизироваться (403) — позитивный контроль', async () => { + const device = await registerDevice(); + const revoke = await api('POST', `/sync/devices/${device.id}/revoke`, { + token: ctx().managerToken, + }); + expect(revoke.status, describeResponse(revoke)).toBe(200); + const status = await api('GET', '/sync/status', { token: device.token }); + expect(status.status).toBe(403); + }); +}); diff --git a/tests/e2e/30-owner-decisions.e2e.ts b/tests/e2e/30-owner-decisions.e2e.ts new file mode 100644 index 0000000..2912a63 --- /dev/null +++ b/tests/e2e/30-owner-decisions.e2e.ts @@ -0,0 +1,270 @@ +// Сценарии решений владельца (ожидаемо КРАСНЫЕ до этапа 4 Codex). +// Публичного API для ролей точки/бухгалтера/директора, пакетов зарплаты и согласия +// на биометрию пока нет, поэтому тесты написаны против наиболее вероятного контракта, +// описанного в tests/e2e/NOTES-FOR-EXECUTORS.md (C1–C3). Если Codex выберет другой +// контракт — меняется только этот файл и NOTES, смысл проверок остаётся. +/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ +import { describe, expect, it } from 'vitest'; +import { api, ctx, dbQuery, describeResponse } from './harness/client.js'; +import { synthetic } from './harness/context.js'; +import { + assignHourly, + createOrgUnits, + daysAgo, + importEmployees, + isoDate, + workedShift, + type OrgUnit, +} from './harness/fixtures.js'; + +type Role = 'point_manager' | 'accountant' | 'director'; + +/** C1: главный менеджер выдаёт ключ роли (standalone-аналог Core RBAC). */ +async function grant(role: Role, orgUnitIds: string[] = []): Promise { + const response = await api('POST', '/access-grants', { + token: ctx().managerToken, + body: { role, org_unit_ids: orgUnitIds, display_name: synthetic(role) }, + }); + expect(response.status, `C1 POST /access-grants (${role}): ${describeResponse(response)}`).toBe( + 201, + ); + return response.body.access_token as string; +} + +async function confirm(sessionId: string) { + const response = await api('PATCH', `/work-sessions/${sessionId}`, { + token: ctx().managerToken, + body: { status: 'confirmed' }, + }); + expect(response.status, `подтверждение смены (см. N2): ${describeResponse(response)}`).toBe(200); +} + +interface World { + unitA: OrgUnit; + unitB: OrgUnit; + employeeId: string; + sessionId: string; + day: Date; +} + +/** Точка A и B, сотрудник точки A со сменой в точке A (по умолчанию подтверждённой; см. N2). */ +async function world(confirmShift = true): Promise { + const [unitA, unitB] = await createOrgUnits(2); + const [employee] = await importEmployees(1); + await assignHourly(employee!.id, unitA!.id); + const day = daysAgo(4); + const sessionId = await workedShift(employee!.id, unitA!.id, day, 8); + if (confirmShift) await confirm(sessionId); + return { unitA: unitA!, unitB: unitB!, employeeId: employee!.id, sessionId, day }; +} + +function period(day: Date) { + const from = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), 1)); + const to = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth() + 1, 0)); + return { from: isoDate(from), to: isoDate(to) }; +} + +/** C2: пакет зарплаты точки за месяц. */ +async function createBatch(token: string, orgUnitId: string, day: Date) { + return api('POST', '/payroll/batches', { + token, + body: { org_unit_id: orgUnitId, ...period(day) }, + }); +} + +async function submittedBatch(w: World, accountant: string): Promise { + const created = await createBatch(accountant, w.unitA.id, w.day); + expect(created.status, `C2 создание пакета: ${describeResponse(created)}`).toBe(201); + const batchId = created.body.id as string; + const submit = await api('POST', `/payroll/batches/${batchId}/submit`, { token: accountant }); + expect(submit.status, `C2 отправка пакета: ${describeResponse(submit)}`).toBe(200); + return batchId; +} + +async function ledgerFor(employeeId: string) { + return dbQuery<{ kind: string; amount_minor: string }>( + `SELECT kind, amount_minor::text AS amount_minor FROM staff_payroll_ledger WHERE employee_id = $1`, + [employeeId], + ); +} + +describe('Решения владельца: роли, пакеты зарплаты, согласие на биометрию', () => { + it('[D6/OWN-01] менеджер точки A запускает расчёт для точки B → 403', async () => { + const w = await world(false); + const managerA = await grant('point_manager', [w.unitA.id]); + const batch = await createBatch(managerA, w.unitB.id, w.day); + const legacy = await api('POST', '/payroll/calculate', { + token: managerA, + body: { month: isoDate(w.day).slice(0, 7), org_unit_id: w.unitB.id }, + }); + expect({ batch: batch.status, legacy: legacy.status }).toEqual({ batch: 403, legacy: 403 }); + }); + + it('[D6/OWN-02] менеджер точки отправляет пакет на согласование → 403', async () => { + const w = await world(false); + const managerA = await grant('point_manager', [w.unitA.id]); + const created = await createBatch(managerA, w.unitA.id, w.day); + expect( + created.status, + `C2 создание пакета менеджером своей точки: ${describeResponse(created)}`, + ).toBe(201); + const submit = await api('POST', `/payroll/batches/${created.body.id}/submit`, { + token: managerA, + }); + expect(submit.status, describeResponse(submit)).toBe(403); + }); + + it('[OWN-03] бухгалтер отправляет пакет → успех, каждый директор получает ровно одно уведомление', async () => { + const accountant = await grant('accountant'); + const directors = [await grant('director'), await grant('director')]; + const w = await world(); + const batchId = await submittedBatch(w, accountant); + for (const director of directors) { + const inbox = await api('GET', '/notifications', { token: director }); + expect(inbox.status, `C2 уведомления директора: ${describeResponse(inbox)}`).toBe(200); + const aboutBatch = ( + inbox.body.notifications as Array<{ type: string; subject_id: string }> + ).filter((n) => n.type === 'payroll.batch.submitted' && n.subject_id === batchId); + expect(aboutBatch).toHaveLength(1); + } + }); + + it('[OWN-04] бухгалтер пытается подписать пакет → 403', async () => { + const accountant = await grant('accountant'); + const w = await world(); + const batchId = await submittedBatch(w, accountant); + const sign = await api('POST', `/payroll/batches/${batchId}/sign`, { token: accountant }); + expect(sign.status, describeResponse(sign)).toBe(403); + }); + + it('[OWN-05] до подписи директора в ledger нет начислений и выплат', async () => { + const accountant = await grant('accountant'); + const w = await world(); + await submittedBatch(w, accountant); + expect(await ledgerFor(w.employeeId)).toEqual([]); + }); + + it('[OWN-06] подпись директора → начисления и выплаты в ledger, пакет и смены неизменяемы', async () => { + const accountant = await grant('accountant'); + const director = await grant('director'); + const w = await world(); + const batchId = await submittedBatch(w, accountant); + const sign = await api('POST', `/payroll/batches/${batchId}/sign`, { token: director }); + expect(sign.status, describeResponse(sign)).toBe(200); + const kinds = new Set((await ledgerFor(w.employeeId)).map((row) => row.kind)); + const editShift = await api('PATCH', `/work-sessions/${w.sessionId}`, { + token: ctx().managerToken, + body: { + status: 'confirmed', + worked_minutes: 1, + correction_note: 'E2E попытка правки после подписи', + }, + }); + const resubmit = await api('POST', `/payroll/batches/${batchId}/submit`, { token: accountant }); + expect({ + accrual: kinds.has('accrual'), + payout: kinds.has('payout'), + editShift: editShift.status, + resubmit: resubmit.status, + }).toEqual({ accrual: true, payout: true, editShift: 409, resubmit: 409 }); + }); + + it('[K19/OWN-07] смена изменена после отправки → подписать нельзя (409), пакет возвращён бухгалтеру', async () => { + const accountant = await grant('accountant'); + const director = await grant('director'); + const w = await world(); + const batchId = await submittedBatch(w, accountant); + const edit = await api('PATCH', `/work-sessions/${w.sessionId}`, { + token: ctx().managerToken, + body: { + status: 'confirmed', + worked_minutes: 420, + correction_note: 'E2E правка после отправки', + }, + }); + expect(edit.status, describeResponse(edit)).toBe(200); + const sign = await api('POST', `/payroll/batches/${batchId}/sign`, { token: director }); + const batch = await api('GET', `/payroll/batches/${batchId}`, { token: accountant }); + expect({ sign: sign.status, status: batch.body?.status }).toEqual({ + sign: 409, + status: 'returned', + }); + }); + + it('[OWN-08] возврат с комментарием → пакет у бухгалтера, комментарий сохранён', async () => { + const accountant = await grant('accountant'); + const director = await grant('director'); + const w = await world(); + const batchId = await submittedBatch(w, accountant); + const comment = synthetic('Комментарий директора'); + const back = await api('POST', `/payroll/batches/${batchId}/return`, { + token: director, + body: { comment }, + }); + expect(back.status, describeResponse(back)).toBe(200); + const batch = await api('GET', `/payroll/batches/${batchId}`, { token: accountant }); + expect({ status: batch.body?.status, comment: batch.body?.return_comment }).toEqual({ + status: 'returned', + comment, + }); + }); + + it('[OWN-09] сотрудник точки A отработал смену в точке B → смена попадает в зарплату точки B', async () => { + const accountant = await grant('accountant'); + const [unitA, unitB] = await createOrgUnits(2); + const [employee] = await importEmployees(1); + await assignHourly(employee!.id, unitA!.id); + const day = daysAgo(5); + const sessionId = await workedShift(employee!.id, unitB!.id, day, 6); + await confirm(sessionId); + const batchB = await createBatch(accountant, unitB!.id, day); + const batchA = await createBatch(accountant, unitA!.id, day); + expect(batchB.status, describeResponse(batchB)).toBe(201); + const sessionsOf = (batch: typeof batchB) => + ((batch.body?.lines ?? []) as Array<{ work_session_id: string }>).map( + (l) => l.work_session_id, + ); + expect({ + inB: sessionsOf(batchB).includes(sessionId), + inA: sessionsOf(batchA).includes(sessionId), + }).toEqual({ + inB: true, + inA: false, + }); + }); + + it('[K17/OWN-10] сотрудник без согласия на биометрию → сервер сообщает терминалу «регистрация запрещена»', async () => { + const [employee] = await importEmployees(1); + const response = await api('GET', `/terminal/employees/${employee!.id}/enrollment`, { + token: ctx().terminalToken, + }); + expect(response.status, `C3: ${describeResponse(response)}`).toBe(200); + expect(response.body).toMatchObject({ registration_allowed: false, reason: 'consent_missing' }); + }); + + it('[K17/OWN-11] согласие отозвано → статус для терминала меняется', async () => { + const [employee] = await importEmployees(1); + const setConsent = (status: 'granted' | 'revoked') => + api('PUT', `/employees/${employee!.id}/biometric-consent`, { + token: ctx().managerToken, + body: { status, document_ref: synthetic('согласие') }, + }); + const enrollment = () => + api('GET', `/terminal/employees/${employee!.id}/enrollment`, { token: ctx().terminalToken }); + const granted = await setConsent('granted'); + expect(granted.status, `C3 PUT biometric-consent: ${describeResponse(granted)}`).toBe(200); + const before = await enrollment(); + const revoked = await setConsent('revoked'); + expect(revoked.status, describeResponse(revoked)).toBe(200); + const after = await enrollment(); + expect({ + before: before.body?.registration_allowed, + after: after.body?.registration_allowed, + reason: after.body?.reason, + }).toEqual({ + before: true, + after: false, + reason: 'consent_revoked', + }); + }); +}); diff --git a/tests/e2e/40-compose-pilot.e2e.ts b/tests/e2e/40-compose-pilot.e2e.ts new file mode 100644 index 0000000..a196cf4 --- /dev/null +++ b/tests/e2e/40-compose-pilot.e2e.ts @@ -0,0 +1,56 @@ +// Статическая проверка deploy/pilot/compose.yaml (аналог tests/unit/deployment.test.ts +// для корневого compose.yaml). Лежит в tests/e2e, а не в tests/unit: у исполнителя +// теста нет права менять tests/unit, а красные пункты (открытые дефекты deploy/) +// должны идти через known-defects.json, не ломая `npm run check`. +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { parse } from 'yaml'; + +const composePath = path.resolve(import.meta.dirname, '../../deploy/pilot/compose.yaml'); +const raw = readFileSync(composePath, 'utf8'); +const compose = parse(raw) as { + services: Record | string[] }>; +}; + +function environmentOf(service: string): Record { + const env = compose.services[service]?.environment ?? {}; + if (Array.isArray(env)) { + return Object.fromEntries( + env.map((entry) => { + const index = entry.indexOf('='); + return index < 0 ? [entry, ''] : [entry.slice(0, index), entry.slice(index + 1)]; + }), + ); + } + return Object.fromEntries(Object.entries(env).map(([key, value]) => [key, String(value)])); +} + +describe('deploy/pilot/compose.yaml', () => { + it('[K1/PILOT-1] все подставляемые переменные обязательны: ${VAR:?сообщение}', () => { + const interpolations = [...raw.matchAll(/\$\{([^}]*)\}/g)].map((match) => match[1]!); + expect(interpolations.length).toBeGreaterThan(0); + const optional = interpolations.filter((inner) => !/^[A-Z0-9_]+:\?.+$/.test(inner)); + expect(optional, 'переменные без :? — пустое значение молча попадёт в контейнер').toEqual([]); + }); + + it('[K2/PILOT-2] ни один сервис не запускается с NODE_ENV=development', () => { + const offenders = Object.keys(compose.services).filter( + (service) => environmentOf(service).NODE_ENV === 'development', + ); + expect(offenders).toEqual([]); + }); + + it('[PILOT-3] у сервиса app нет учётных данных мигратора', () => { + const env = environmentOf('app'); + const keys = Object.keys(env).filter((key) => + ['DATABASE_MIGRATION_URL', 'POSTGRES_MIGRATION_PASSWORD', 'POSTGRES_MIGRATION_USER'].includes( + key, + ), + ); + const values = Object.entries(env) + .filter(([, value]) => /POSTGRES_MIGRATION_PASSWORD|krona_migrator/.test(value)) + .map(([key]) => key); + expect({ keys, values }).toEqual({ keys: [], values: [] }); + }); +}); diff --git a/tests/e2e/50-money-and-errors.e2e.ts b/tests/e2e/50-money-and-errors.e2e.ts new file mode 100644 index 0000000..1932669 --- /dev/null +++ b/tests/e2e/50-money-and-errors.e2e.ts @@ -0,0 +1,287 @@ +// Недостающие сценарии из аудита владельца (TASK-GROK-2 §3): денежная логика K6–K9, +// удаление точки с устройством (S9), утечка ошибок PostgreSQL (A4), дата расчёта (N12-DATE). +// Каждый тест проверяет ПРАВИЛЬНОЕ поведение: красный = открытый дефект. Где путь +// упирается в K3 (outbox без occurred_at), assert предусловия прямо называет K3. +/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ +import pg from 'pg'; +import { describe, expect, it } from 'vitest'; +import { api, ctx, dbQuery, describeResponse, type ApiResponse } from './harness/client.js'; +import { randomUlid, synthetic } from './harness/context.js'; +import { + SYNTHETIC_RATE_MINOR, + assignHourly, + createOrgUnits, + daysAgo, + importEmployees, + isoDate, + workedShift, +} from './harness/fixtures.js'; + +const monthOf = (date: Date) => date.toISOString().slice(0, 7); + +async function ledgerTotal(employeeId: string, kinds = ['accrual', 'adjustment']) { + const rows = await dbQuery<{ total: string }>( + `SELECT COALESCE(SUM(amount_minor), 0)::text AS total FROM staff_payroll_ledger + WHERE employee_id = $1 AND kind = ANY($2::text[])`, + [employeeId, kinds], + ); + return BigInt(rows[0]!.total); +} + +async function confirmShift(sessionId: string) { + const confirm = await api('PATCH', `/work-sessions/${sessionId}`, { + token: ctx().managerToken, + body: { status: 'confirmed' }, + }); + expect( + confirm.status, + `подтверждение смены (предусловие; падает, пока открыт K3): ${describeResponse(confirm)}`, + ).toBe(200); +} + +/** Расчёт и утверждение (подпись) ведомости точки за месяц дня `day`. */ +async function calculateAndApprove(unitId: string, day: Date): Promise { + const calculate = await api('POST', '/payroll/calculate', { + token: ctx().managerToken, + body: { month: monthOf(day), org_unit_id: unitId }, + }); + expect(calculate.status, `расчёт (предусловие, K3): ${describeResponse(calculate)}`).toBe(200); + const runId = (calculate.body.run_id ?? calculate.body.id) as string; + const approve = await api('POST', `/payroll/runs/${runId}/approve`, { + token: ctx().managerToken, + }); + expect(approve.status, `утверждение (предусловие, K3): ${describeResponse(approve)}`).toBe(200); + return runId; +} + +async function payRuleId(code: string): Promise { + const response = await api('GET', '/pay-rules', { token: ctx().managerToken }); + expect(response.status, describeResponse(response)).toBe(200); + const rule = (response.body.pay_rules as Array<{ id: string; code: string }>).find( + (item) => item.code === code, + ); + expect(rule, `правило ${code} должно быть в пресете`).toBeTruthy(); + return rule!.id; +} + +/** Признаки ошибки PostgreSQL в теле ответа клиенту (SQLSTATE, имена отношений/столбцов). */ +function postgresLeak(response: ApiResponse): string[] { + const leaks: string[] = []; + const body = (response.body ?? {}) as Record; + if (typeof body.code === 'string' && /^[0-9A-Z]{5}$/.test(body.code)) { + leaks.push(`code=${body.code}`); + } + if ( + /relation "|column "|violates|constraint|syntax for type|deduced for parameter/i.test( + response.text, + ) + ) { + leaks.push(`text=${response.text.slice(0, 120)}`); + } + return leaks; +} + +describe('Деньги, S9, A4 и даты на реальной PostgreSQL', () => { + it('[K6/D5] отклонение смены из подписанной ведомости → 409 или сторно (начисление не остаётся)', async () => { + const [unit] = await createOrgUnits(1); + const [employee] = await importEmployees(1); + await assignHourly(employee!.id, unit!.id); + const day = daysAgo(4); + const sessionId = await workedShift(employee!.id, unit!.id, day, 8); + await confirmShift(sessionId); + await calculateAndApprove(unit!.id, day); + expect(await ledgerTotal(employee!.id)).toBe(BigInt(8 * SYNTHETIC_RATE_MINOR)); + + const reject = await api('PATCH', `/work-sessions/${sessionId}`, { + token: ctx().managerToken, + body: { status: 'rejected', correction_note: synthetic('отказ после подписи') }, + }); + if (reject.status === 409) return; // смена подписанной ведомости неизменяема — верно + expect(reject.status, describeResponse(reject)).toBe(200); + expect( + await ledgerTotal(employee!.id), + 'после отклонения оплаченной смены должно быть сторно: итог начислений 0', + ).toBe(0n); + }); + + it('[K7/D2] повторный импорт книги: итог в реестре равен сумме по книге (две точки в один день)', async () => { + const [employee] = await importEmployees(1); + const date = isoDate(daysAgo(5)); + const keyA = `xlsx:payroll:accrual:e2e-${randomUlid()}-unit-a`; + const keyB = `xlsx:payroll:accrual:e2e-${randomUlid()}-unit-b`; + const entry = (externalKey: string, amount: number) => ({ + external_key: externalKey, + employee_external_ref: employee!.external_ref, + business_date: date, + kind: 'accrual', + amount_minor: amount, + }); + const importBook = (amountA: number, amountB: number) => + api('POST', '/imports/owner-workbook', { + token: ctx().managerToken, + body: { + source_name: synthetic('import-book'), + payroll_entries: [entry(keyA, amountA), entry(keyB, amountB)], + }, + }); + + const first = await importBook(11_100, 22_200); + expect(first.status, describeResponse(first)).toBeLessThan(300); + const afterFirst = await ledgerTotal(employee!.id); + const second = await importBook(15_500, 22_200); + expect(second.status, describeResponse(second)).toBeLessThan(300); + const afterSecond = await ledgerTotal(employee!.id); + expect({ afterFirst, afterSecond }).toEqual({ + afterFirst: 33_300n, // 111,00 ₽ + 222,00 ₽ по книге + afterSecond: 37_700n, // 155,00 ₽ + 222,00 ₽ после исправления суммы точки A + }); + }); + + it('[K8/D1] подписанная ведомость воспроизводится после смены ставки (сумма та же)', async () => { + const [unit] = await createOrgUnits(1); + const [employee] = await importEmployees(1); + await assignHourly(employee!.id, unit!.id); + const day = daysAgo(6); + const sessionId = await workedShift(employee!.id, unit!.id, day, 8); + await confirmShift(sessionId); + const runId = await calculateAndApprove(unit!.id, day); + const signed = await api('GET', `/payroll/runs/${runId}`, { token: ctx().managerToken }); + expect(signed.status, describeResponse(signed)).toBe(200); + + // Меняем ставку «задним числом» (то же effective_from) после подписи. + const change = await api('PUT', `/employees/${employee!.id}/pay-assignment`, { + token: ctx().managerToken, + body: { + pay_rule_id: await payRuleId('hourly'), + org_unit_id: unit!.id, + rate_minor: SYNTHETIC_RATE_MINOR * 3, + effective_from: '2026-01-01', + }, + }); + expect(change.status, describeResponse(change)).toBe(200); + + // Предполагаемый контракт C4 (NOTES-FOR-EXECUTORS): пересчёт подписанной ведомости + // для проверки по снимку правил и ставок на момент подписи. + const verify = await api('GET', `/payroll/runs/${runId}/verify`, { + token: ctx().managerToken, + }); + expect(verify.status, `C4 verify: ${describeResponse(verify)}`).toBe(200); + expect({ + matches: verify.body.matches, + recalculated: String(verify.body.recalculated_total_minor), + }).toEqual({ matches: true, recalculated: String(8 * SYNTHETIC_RATE_MINOR) }); + }); + + it('[K9/D3] официант по пресету получает оплату смены из параметров пресета', async () => { + const [unit] = await createOrgUnits(1); + const [employee] = await importEmployees(1); + const shiftRate = 111_100; // синтетические 1 111,00 ₽ за смену (кратно рублю, см. округление) + // Предполагаемый контракт: параметры пресета передаются объектом `params` + // (ключи — param_declarations правила). + const assign = await api('PUT', `/employees/${employee!.id}/pay-assignment`, { + token: ctx().managerToken, + body: { + pay_rule_id: await payRuleId('waiter_upsell'), + org_unit_id: unit!.id, + effective_from: '2026-01-01', + params: { + shift_rate_minor: shiftRate, + percent_bp: 0, + upsell_rate_minor: 0, + avg_check_target_minor: 99_999_999, + avg_check_bonus_minor: 0, + }, + }, + }); + expect(assign.status, describeResponse(assign)).toBe(200); + const saved = await api('GET', `/employees/${employee!.id}/pay-assignment`, { + token: ctx().managerToken, + }); + expect( + saved.body.params?.shift_rate_minor, + `параметр пресета должен сохраниться: ${describeResponse(saved)}`, + ).toBe(shiftRate); + + const day = daysAgo(7); + const sessionId = await workedShift(employee!.id, unit!.id, day, 8); + await confirmShift(sessionId); + await calculateAndApprove(unit!.id, day); + expect(await ledgerTotal(employee!.id)).toBe(BigInt(shiftRate)); + }); + + it('[S9] удаление точки с привязанным устройством: удалена или понятный отказ, не 23502', async () => { + const [unit] = await createOrgUnits(1); + const device = await api('POST', '/sync/devices', { + token: ctx().managerToken, + body: { + device_code: `E2E-${randomUlid().slice(-10)}`, + name: synthetic('Терминал S9'), + org_unit_id: unit!.id, + }, + }); + expect(device.status, describeResponse(device)).toBe(201); + // Публичного API удаления точки нет, поэтому удаляем так же, как аудит владельца: + // SQL-запросом владельца схемы (мигратор), в tenant-контексте (FORCE RLS). + const migrator = new pg.Client({ connectionString: ctx().migrationDatabaseUrl }); + await migrator.connect(); + let outcome = 'deleted'; + try { + await migrator.query('BEGIN'); + await migrator.query("SELECT set_config('app.tenant_id', $1, true)", [ctx().tenantId]); + await migrator.query('DELETE FROM staff_org_units WHERE id = $1', [unit!.id]); + await migrator.query('ROLLBACK'); + } catch (error) { + outcome = `${(error as { code?: string }).code} ${(error as Error).message}`; + await migrator.query('ROLLBACK').catch(() => undefined); + } finally { + await migrator.end(); + } + // Верно: удалено, либо отказ внешнего ключа 23503 (понятный запрет). + expect(outcome, 'удаление точки с устройством').toMatch(/^(deleted|23503 )/); + }); + + it('[A4] ответы 500 не содержат кода и текста ошибки PostgreSQL', async () => { + const [unit] = await createOrgUnits(1); + const probes: Array<[string, ApiResponse]> = [ + [ + 'POST /employees', + await api('POST', '/employees', { + token: ctx().managerToken, + body: { + personnel_number: `E2E-${randomUlid().slice(-8)}`, + display_name: synthetic('Сотрудник'), + position: 'E2E-должность', + }, + }), + ], + [ + 'POST /payroll/calculate month=2026-13', + await api('POST', '/payroll/calculate', { + token: ctx().managerToken, + body: { month: '2026-13', org_unit_id: unit!.id }, + }), + ], + ]; + const leaks = probes + .filter(([, response]) => response.status >= 500) + .map(([name, response]) => ({ name, leaks: postgresLeak(response) })) + .filter((item) => item.leaks.length > 0); + expect(leaks, 'тела 500 с деталями PostgreSQL').toEqual([]); + }); + + it('[N12-DATE] расчёт с несуществующей датой (month=2026-13, 2026-02-30) → 400', async () => { + const [unit] = await createOrgUnits(1); + const month = await api('POST', '/payroll/calculate', { + token: ctx().managerToken, + body: { month: '2026-13', org_unit_id: unit!.id }, + }); + const range = await api('POST', '/payroll/calculate', { + token: ctx().managerToken, + body: { from: '2026-02-01', to: '2026-02-30', org_unit_id: unit!.id }, + }); + expect( + { month: month.status, range: range.status }, + `${describeResponse(month)} | ${describeResponse(range)}`, + ).toEqual({ month: 400, range: 400 }); + }); +}); 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/NOTES-FOR-EXECUTORS.md b/tests/e2e/NOTES-FOR-EXECUTORS.md new file mode 100644 index 0000000..5823ed3 --- /dev/null +++ b/tests/e2e/NOTES-FOR-EXECUTORS.md @@ -0,0 +1,253 @@ +# Заметки для исполнителей (Codex / Antigravity) + +Автор: Grok (стенд тестов и независимая приёмка). Я не меняю `src/`, `apps/`, +`migrations/`, `deploy/`; всё, что требует изменения продукта, описано здесь. + +Как запускать: + +```sh +# bootstrap-админ PostgreSQL 17 (только создаёт роли и БД; сервер и миграции под ним не работают) +export E2E_PG_ADMIN_URL=postgres://:@127.0.0.1:55433/postgres +node scripts/test/run-e2e.mjs # храповик known-defects (как в CI) +node scripts/test/run-e2e.mjs --no-known-defects # строгий режим: все дефекты красные +E2E_MIGRATIONS=roundtrip node scripts/test/run-e2e.mjs # + откат всех миграций перед запуском +./scripts/test/ci-local.sh # локальный эквивалент verify/format/e2e +``` + +**Правило храповика.** Исправили дефект → его сценарий позеленел → +`run-e2e.mjs` падает с «дефект исправлен, обновите known-defects.json» — +удалите ID из `tests/e2e/known-defects.json` в том же коммите. Новый красный +сценарий, которого нет в списке, — регрессия. + +## 1. Предполагаемый контракт для решений владельца (OWN-01..11) — Codex, этап 4 + +API пока нет, поэтому сценарии `tests/e2e/30-owner-decisions.e2e.ts` написаны +против наиболее вероятного контракта. Если выбираете другой — поменяйте тесты и +этот раздел в том же PR, смысл проверок (кто что может и когда) менять нельзя. +Все пути относительно `/api/v1/staff`. + +### C1. Роли точки/бухгалтера/директора (standalone-аналог Core RBAC) + +- `POST /access-grants` (только главный менеджер, `staff:employee:write`) + тело `{ "role": "point_manager" | "accountant" | "director", "org_unit_ids": [""], "display_name": "..." }` + → `201 { "id", "role", "org_unit_ids", "access_token" }`. Токен показывается один раз, + хранится как хеш (как ключи сотрудников), используется как `Authorization: Bearer`. +- `point_manager` видит/считает только свои `org_unit_ids`; чужая точка → `403` + (и для нового `POST /payroll/batches`, и для старого `POST /payroll/calculate`). +- В platform-режиме те же роли приходят из Core (User ≠ Employee, AGENTS.md). + +### C2. Пакет зарплаты точки и согласование + +| Метод и путь | Кто | Результат | +| ------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `POST /payroll/batches` `{org_unit_id, from, to}` | accountant; point_manager своей точки | `201 {id, status:"draft", org_unit_id, lines:[{work_session_id, employee_id, org_unit_id, amount_minor}]}` | +| `POST /payroll/batches/:id/submit` | только accountant | `200 {status:"submitted"}`; point_manager → `403`; повтор после подписи → `409` | +| `GET /notifications` | любой пользователь | `200 {notifications:[{type:"payroll.batch.submitted", subject_id:}]}`; каждому директору ровно одно уведомление на отправку | +| `POST /payroll/batches/:id/sign` | только director | `200 {status:"signed"}`; accountant → `403`; если смена пакета изменилась после отправки → `409`, пакет получает `status:"returned"` | +| `POST /payroll/batches/:id/return` `{comment}` | director | `200 {status:"returned", return_comment}` | +| `GET /payroll/batches/:id` | accountant, director | `200 {status, return_comment, lines:[...]}` | + +Инварианты, которые проверяют тесты: + +- до подписи в `staff_payroll_ledger` для сотрудников пакета нет ни `accrual`, ни `payout` (OWN-05); +- подпись атомарно пишет `accrual` и `payout`; после подписи `PATCH /work-sessions/:id` + для смен пакета → `409`, пакет не отправляется повторно (OWN-06); +- строка пакета принадлежит точке, где отработана смена (`staff_work_sessions.org_unit_id`), + а не основной точке сотрудника (OWN-09). + +### C3. Согласие на биометрию + +- `PUT /employees/:id/biometric-consent` `{ "status": "granted" | "revoked", "document_ref": "..." }` + (главный менеджер) → `200`. +- `GET /terminal/employees/:id/enrollment` (терминальный токен) → + `200 { "employee_id", "registration_allowed": boolean, "reason": "consent_missing" | "consent_revoked" | null }`. + Без записи о согласии — `false/consent_missing` (OWN-10); после отзыва — `false/consent_revoked` (OWN-11). +- Шаблоны отпечатков на сервер не передаются (docs/TERMINAL-API-REQUIREMENTS.md); + сервер хранит только факт согласия. Тот же флаг стоит отдать в `/sync/pull` (employee payload). + +Сейчас все OWN красные с `404 Route ... not found` (C1–C3 не реализованы); +OWN-03..09 дополнительно упрутся в K3/N2 (подтверждение смены падает 500). + +ID в названиях тестов — двойные «владелец/мой» (`[K3/N2]`, `[D6/OWN-01]`, `[K17/OWN-10]`, +`[K19/OWN-07]`), по таблице TASK-GROK-2 §2 и AUDIT.md. + +### C4. Проверочный пересчёт подписанной ведомости (K8/D1) + +- `GET /payroll/runs/:id/verify` (право `staff:payroll:read`) → + `200 { "run_id", "stored_total_minor", "recalculated_total_minor", "matches": boolean }`. +- Пересчёт идёт по снимку версий правил, параметров и ставок, действовавших при подписи + (и ревизиям смен), а не по текущим значениям. После смены ставки `matches` остаётся `true`. + +### C5. Параметры пресета в назначении оплаты (K9/D3) + +- `PUT /employees/:id/pay-assignment` принимает `params: { : integer }`, ключи — из + `param_declarations` выбранного правила (например, `shift_rate_minor`, `percent_bp`, + `upsell_rate_minor`, `avg_check_target_minor`, `avg_check_bonus_minor` у `waiter_upsell`); + неизвестный ключ → 400. `GET .../pay-assignment` возвращает их в `params`. +- Старые поля `rate_minor`/`percent_bp`/`guarantee_minor` можно оставить как сокращения. + +### C6. Перенос арендатора пилота (PILOT-REHOME, TASK-CODEX-2 п.2.2) + +Что проверяет `tests/e2e/60-pilot-rehome.e2e.ts` (`[PILOT-REHOME]`): + +- Запуск: `DATABASE_ADMIN_URL=<админ БД> node scripts/rehome-tenant.mjs --from 01J00000000000000000000000 --to 01M2R6AC5652NPT3MG34DKN4ZP [--dry-run] [--json]`. + Стенд передаёт `DATABASE_ADMIN_URL` суперпользователя в БД стенда (`E2E_DB_ADMIN_URL` или + `E2E_PG_ADMIN_URL` с именем БД). +- `--json`: последней строкой stdout — `{"tables":{"<таблица>":<строк у --from>},"ledger_amount_minor_sum":<число или строка>}`. + Таблицы с нулём можно не печатать. +- `--dry-run`: exit 0, те же числа, в БД ничего не меняется. +- Перенос: exit 0; под `--from` 0 строк во **всех** таблицах с `tenant_id` (список из + `information_schema.columns`, не вручную); у `--to` те же счётчики и та же + `SUM(amount_minor)` в `staff_payroll_ledger`; другие арендаторы не тронуты. +- В `staff_audit_log` у нового арендатора появляется запись с `action`, содержащим `rehome` + (например, `tenant.rehomed`, старый tenant — в `new_values`). +- После переноса `krona_staff_app` видит тех же сотрудников (те же `id`) под новым tenant и никого + под старым; `UPDATE staff_payroll_ledger` ролью приложения по-прежнему отклоняется. +- Повторный запуск (под `--from` пусто / у `--to` уже есть данные) — exit ≠ 0, ничего не меняет. + +Что нужно учесть (видно по схеме, `pg_policy`/`pg_constraint` на PG17): + +- У `staff_payroll_ledger` нет политики UPDATE/DELETE, у приложения нет права UPDATE, FORCE RLS + действует и на владельца: `krona_migrator` строки журнала не перенесёт. Нужна роль с обходом RLS + (суперпользователь/BYPASSRLS) — поэтому `DATABASE_ADMIN_URL`. +- Внешние ключи составные `(tenant_id, …)` и не DEFERRABLE: переносить родителя раньше детей + нельзя — нужен порядок, отложенные проверки или `session_replication_role = replica` в одной транзакции. +- Политики INSERT `staff_audit_log`, `staff_payroll_ledger`, `staff_attendance_events`, + `staff_timesheet_entries` требуют `actor_id/created_by/updated_by = current_setting('app.user_id')` — + для записи аудита без обхода RLS выставить `app.user_id`. + +## 2. Дефекты продукта, найденные сценариями (все красные сейчас) + +Команда и фактический вывод — `tests/e2e/evidence/e2e-run.txt`; таблица — `tests/e2e/REPORT.md`. + +| ID | Файл | Что исправить | +| ---------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| K4/N1 | `src/portal.ts` POST /employees | `42P08 inconsistent types deduced for parameter $2`: явные `$2::varchar(26)`, `$5::text` или отдельный запрос профессии | +| K3/N2 | `src/portal.ts` PATCH /work-sessions, `src/domain/payroll/service.ts` calculate/approve | `staff_outbox.occurred_at` NULL (23502): передавать `occurred_at` или `DEFAULT now()` новой миграцией | +| K5/N4 | `service.ts` | после N2 проверить: пересекающийся период не должен начислять смену повторно (409 или исключение уже начисленных смен) | +| K12/N3 | push-подписки | allowlist push-сервисов / запрет приватных адресов, `redirect:'manual'`; сейчас сервер подключается к `127.0.0.1` | +| N9 | `push-dispatcher.ts` | `ON CONFLICT ... DO UPDATE SET employee_id` → 409 для чужого endpoint | +| N10 | GET /professions, /org-units | требовать право справочника; терминал сейчас получает 200 | +| N11 | импорт | валидировать `kind` и схему `items` → 400 вместо 500 (23514) | +| N12 | PUT pay-assignment | `additionalProperties:false`, верхние границы сумм | +| N12-DATE | `POST /payroll/calculate` | `month=2026-13` → 500 `22008`; проверять месяц 01–12 → 400 (диапазон `2026-02-30` уже даёт 400) | +| K6/D5 | `portal.ts` PATCH /work-sessions | смена подписанной ведомости: `409`, либо сторно в ledger; сейчас (на fix-live-database) 200 и начисление остаётся | +| K7/D2 | `operations.ts` импорт `payroll_entries` | дельта по сумме всех записей дня, вставка по ключу: две точки за день → 222,00 вместо 333,00 ₽; считать дельту по ключу | +| K8/D1 | расчёт/подпись | снимок правил и ставок в подписанной ведомости; контракт проверки C4 (ниже) | +| K9/D3 | PUT pay-assignment | сохранять `params` по `param_declarations` правила (C5 ниже); сейчас `shift_rate_minor` официанта теряется | +| S9 | миграция 0011 FK устройств | `ON DELETE SET NULL` на составном `(tenant_id, org_unit_id)` обнуляет `tenant_id` → 23502; `SET NULL (org_unit_id)` или RESTRICT | +| A4 | обработчик ошибок | в теле 5xx нет SQLSTATE (`code`) и текста PostgreSQL; подробности только в лог | +| K1/F2 | `config.ts`/`identity.ts` | standalone без токенов не должен выдавать права (процесс без токенов отдаёт 200 на /portal, /payroll, POST /professions) | +| K10/F3 | POST/DELETE /session | в cookie — идентификатор сессии, а не сырой токен; logout отзывает сессию | +| K11/F4 | вход | rate limit (тест ждёт 429 не позже 30 неверных попыток; идёт на отдельном процессе, чтобы лимит не мешал другим сценариям) | +| F7 | POST /attendance/events | использовать `Idempotency-Key` / `staff_idempotency_keys` | +| F10 | `assertSameOrigin` | мутации по cookie без `Origin` отклонять | +| K13/SYNC-A, K13/SYNC-B | `src/domain/sync/service.ts` | устройство не должно выставлять `confirmed`/`approved` (сейчас `applied`) | +| SYNC-C0 | `pull()` bootstrap | `SELECT ... status FROM staff_org_units` — такого столбца нет → 500 на любой начальной загрузке | +| S5/SYNC-C | `pull()` bootstrap | при `has_more=true` отдаётся `next_cursor=latest`, остаток после 100 сущностей теряется; нужен курсор снимка | +| S5/SYNC-D | портал/импорт | изменения вне sync не пишутся в `staff_sync_change_log` → терминал не узнаёт о новых/изменённых сотрудниках | +| S4/SYNC-E | `executeCommand` | проверять `status='active'` сотрудника | +| S4/SYNC-F | `executeCommand` | сохранять `org_unit_id` в `staff_attendance_events`; payload не может переопределить точку устройства | +| S3/SYNC-G | `push()` | ошибки БД одной команды → `rejected` для неё, а не 500 и откат всего пакета | +| S8/SYNC-H | `/sync/devices` | `assertCookieMutationOrigin` как в портале | +| K1/SYNC-I | `requireManagerContext` | без токенов не выдавать права (как F2) | +| S2/SYNC-J | `executeCommand` | окно времени отметки как в POST /attendance/events (−180 дней … +5 мин) | +| SYNC-K | `acknowledge()` | отклонять курсор больше `MAX(cursor_seq)` | + +## 3. deploy/ (K1/PILOT-1, K2/PILOT-2) — Codex + +- PILOT-1: все подстановки в `deploy/pilot/compose.yaml` → `${VAR:?VAR is required}` + (сейчас 14 без `:?`: пустой `STANDALONE_MANAGER_TOKEN` молча превращает pilot в F2). +- PILOT-2: `NODE_ENV: development` в `app`. Конфликт: `config.ts` запрещает + `STAFF_RUNTIME_MODE=standalone` при `NODE_ENV=production`. Нужно решение: либо + разрешить standalone в production при обязательных токенах и HTTPS, либо + отдельный режим для pilot. Тест требует только «не development». +- N6: `scripts/create-app-role.mjs` не работает мигратором без SUPERUSER + («Only roles with SUPERUSER may change the SUPERUSER attribute»). CI и e2e + обходятся `scripts/test/provision-pg.mjs` (роли создаёт bootstrap-админ, как + `docker/postgres/init-app-role.sh`). В pilot мигратор = `POSTGRES_USER` = + суперпользователь; рекомендую схему как в provision-pg.mjs. + +## 4. CI + +- Задаче `docker-build` нужен раннер с меткой `docker-host` (Docker daemon). + Без него задача ждёт в очереди; остальные задачи не блокируются. +- `format` красная, пока не исправлен F14 (23 файла на замер 2026-09-26, в основном `src/`, `docs/`, `tests/unit/`). +- `format` станет зелёной после влития `codex/fix-format-baseline` (проверено на ветке). +- Задача `windows` выключена, пока нет раннера с меткой `windows`: включается переменной + репозитория `KRONA_WINDOWS_RUNNER=true`; для полного e2e нужен секрет + `WINDOWS_E2E_PG_ADMIN_URL` (локальная PostgreSQL 17 на раннере). +- `tests/e2e/evidence/` не хранится в git; в CI это артефакт `e2e-evidence`. +- Реальный Forgejo-раннер мной не запускался (нет API-токена) — не проверено. + +## 5. N13 (гонка первого расчёта) и утечка текста ошибок БД + +- `scripts/test/n13-race.mjs`, вывод `tests/e2e/evidence/n13-race.txt`: 10/25/50 + параллельных первых `POST /payroll/calculate` × 5 попыток. Как есть — все 500 (N2), + гонку не видно. В одноразовой БД с обходом N2 (`DEFAULT now()` только там) — 0 из 30 + некорректных попыток (без смен и с подтверждённой сменой): один `run_id`, один расчёт в БД. +- Почему не воспроизводится (вывод из кода, не проверено отдельным экспериментом): + `calculatePayroll` сначала вызывает `ensureDefaultPayRules`, а там + `INSERT ... ON CONFLICT DO UPDATE` по одним и тем же строкам `staff_pay_rules` тенанта. + Первая транзакция держит блокировки этих строк до коммита, остальные ждут — расчёты + одного тенанта выполняются по очереди случайно. Если upsert правил вынесут из расчёта + или сделают `DO NOTHING`, окно `SELECT ... FOR UPDATE` (строки нет) → `INSERT ... ON +CONFLICT DO NOTHING` со своим `runId` → строки/UPDATE по несуществующему `runId` вернётся. + Рекомендация: явный `pg_advisory_xact_lock` по (tenant, org_unit, period_start) или + `INSERT ... ON CONFLICT DO UPDATE ... RETURNING id`. Скрипт стоит перезапустить после + такого изменения. +- Ответы 500 содержат сырой текст ошибки PostgreSQL (например + `null value in column "occurred_at" of relation "staff_outbox" ...`) — клиенту видна + схема БД. Для 5xx отдавать нейтральное сообщение, подробности — только в лог. + +## 6. Приёмка веток исполнителей (2026-09-26) + +Подробно — `tests/e2e/REPORT.md` и `tests/e2e/report-evidence/acceptance-*.txt`. + +- `codex/fix-live-database` (ea68e12, перепроверено на b289dd6): K3/N2 и K4/N1 зелёные на живой PostgreSQL 17. + Дальше по цепочке открылись K5/N4 (196800 вместо 98400 — двойное начисление) и + K6/D5 (отклонение оплаченной смены: 200, начисление осталось). A4 частично: `POST /employees` + больше не 500, но `month=2026-13` по-прежнему отдаёт `code=22008` с текстом. +- `codex/fix-format-baseline` (fd1ec3d): `prettier --check .` чистый. **Но коммит «style» + содержит функциональное исправление Antigravity**: `src/domain/sync/service.ts` и + `tests/unit/sync.test.ts` после нормализации prettier совпадают с + `antigravity/fix-reject-device-shifts` (0 строк diff) и отличаются от 331e490 на 255/139 + строк. Изменение поведения стоит вынести в отдельный коммит с объяснением или влить + ветку Antigravity отдельно, иначе история смешивает темы (AGENTS.md). +- `antigravity/fix-reject-device-shifts` (795bba0, перепроверено на 1df2019 — уже на f5ac179): + K13/SYNC-A и K13/SYNC-B зелёные, остальное без изменений. +- Ни одна ветка не влита в основную (f5ac179), поэтому ID из `known-defects.json` не удалены. +- Суммы в тестах кратны рублю: расчёт округляет строку до `rounding_unit_minor` + (по умолчанию 100 коп.). Если округление должно быть другим — это решение продукта, не теста. + +## 7. Круг 3 (TASK-GROK-3, 2026-09-26) + +Команды и полный вывод — `tests/e2e/REPORT.md` («Круг 3») и `tests/e2e/report-evidence/round3-*.md`. +Приёмка одной командой: `node scripts/test/accept-branch.mjs <ветка>…` (нужен `E2E_PG_ADMIN_URL` +или готовые роли в `E2E_MIGRATOR_URL` + `E2E_APP_URL`). + +- **Antigravity, `sync-bootstrap.test.ts` (8baad55)**: `Error: Called end on pool more than once` при + `Tests 2 passed`. Причина: `app.close()` в тесте вызывает хук `onClose` → `database.close()` + (`src/app.ts:148`), затем `afterAll` закрывает тот же `Database` ещё раз. На текущей голове + `24cc18d` файл зелёный (`1 passed (1) | 2 passed (2)`). +- **Antigravity, `sync-rejection.test.ts` (1df2019)**: `column "created_by" of relation "staff_employees" +does not exist` в `beforeAll` (`Tests 2 skipped`). На `6c5bf36`/`b283736` исправлено. +- **Конфликты**: на прежних головах `1df2019 × 8baad55` и `8baad55 × 513cdff` конфликтовали в + `src/domain/sync/service.ts`; на текущих головах (fetch 16:16 NOVT) конфликтов нет ни в одной из 55 пар. +- **Codex, `codex/fix-format-baseline`**: на `513cdff` в `service.ts`/`sync.test.ts` было 232/130 строк сверх + prettier, все есть в `antigravity/fix-reject-device-shifts`; на `a05777c` — только форматирование (19 файлов). +- **Codex, `live-postgres-remediation.test.ts`**: выполняется только при `DATABASE_TEST_ISOLATED=true`, + `DATABASE_TEST_GUARD_TOKEN`, таблице `krona_staff_test_guard` с этим маркером и имени БД + `krona_staff_audit_*`. Соглашение нигде, кроме самого теста, не описано, и ни CI, ни + `scripts/test/*` его не выполняют — в `npm run check` файл всегда `skipped`. `accept-branch` ставит + предохранитель в одноразовой БД: `1 passed (1)`. Предложение: описать соглашение в README/AGENTS + и после слияния включить его в CI (могу сделать в своей зоне, `.forgejo/workflows/ci.yml`). +- **N5 до сих пор в основе**: `tenant-isolation.test.ts` красный на `b0243d5` и всех ветках от + `f5ac179` (`permission denied for table staff_employees`); исправление — в `grok/e2e-harness`. + Локальная сборка основа + fix-live-database + fix-sync-c0-bootstrap + grok/e2e-harness: + интеграционные 7/7, храповик просит убрать ровно K3/N2, K4/N1, K13/SYNC-A, K13/SYNC-B, SYNC-C0. +- **Схема (низкий приоритет)**: `staff_notification_deliveries.outbox_id → staff_outbox(id)` — единственный + внешний ключ без `tenant_id`; все остальные составные `(tenant_id, …)`. База не мешает доставке + ссылаться на событие outbox другого арендатора (RLS фильтрует чтение, но не ссылку). Предложение: + `UNIQUE (tenant_id, id)` на `staff_outbox` и FK `(tenant_id, outbox_id)`. +- **PILOT-REHOME**: скрипта нет → `[PILOT-REHOME]` красный; контракт — C6. diff --git a/tests/e2e/REPORT.md b/tests/e2e/REPORT.md new file mode 100644 index 0000000..7e3a8dd --- /dev/null +++ b/tests/e2e/REPORT.md @@ -0,0 +1,371 @@ +# PR: e2e-стенд на реальной PostgreSQL 17 и независимая приёмка (grok/e2e-harness) + +База: `codex/bootstrap-platform-foundation` @ `f5ac179` (содержит `331e490`). Ветка перебазирована +без конфликтов. Сравнение: + + +Изменены только `tests/e2e/**`, `tests/integration/tenant-isolation.test.ts`, +`.forgejo/workflows/ci.yml`, `scripts/test/**` и одна строка в корневом `.gitignore` +(`tests/e2e/evidence/`, по требованию TASK-GROK-2 §1). `src/`, `apps/`, `migrations/`, +`deploy/`, `package.json` не тронуты. Всё, что требует правки продукта, — в +[`NOTES-FOR-EXECUTORS.md`](NOTES-FOR-EXECUTORS.md). + +## Зачем + +156 зелёных тестов на поддельной БД не заметили, что создание сотрудника, подтверждение +смены и расчёт зарплаты на реальной PostgreSQL падают всегда. Стенд запускает реальный сервер +под ролью приложения без привилегий на реальной PostgreSQL 17, миграции применяет мигратор без +SUPERUSER, все токены и пароли случайные (`crypto.randomBytes`). Каждый сценарий проверяет +**правильное** поведение: красный = открытый дефект. + +## Что изменилось во втором круге (TASK-GROK-2) + +| Пункт | Что сделано | +| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| §1 | Запуск без `.cmd`-обёрток: `scripts/test/node-commands.mjs` — всегда `process.execPath` + JS-точка входа из `require.resolve` (`vitest.mjs`, `prettier.cjs`); сервер — один процесс `node --import tsx src/server.ts` (без дочернего процесса CLI tsx, чтобы `kill()` под Windows не оставлял сироту). Проверка на Linux — `tests/e2e/harness/node-commands.test.ts` (в `npm test`) | +| §1 | `tests/e2e/evidence/` убран из git и добавлен в `.gitignore`; в CI — артефакт `e2e-evidence`. Разовые доказательства (N5, N13, приёмка) — в `tests/e2e/report-evidence/`, скрипты туда не пишут | +| §1 | Задача `windows` в CI (включается переменной `KRONA_WINDOWS_RUNNER=true`, раннера пока нет) | +| §1 | Перебазирование на `f5ac179` — без конфликтов | +| §2 | Двойные ID `[владелец/мой]` в названиях тестов и ключах `known-defects.json` | +| §3 | Новые сценарии `[K6/D5]`, `[K7/D2]`, `[K8/D1]`, `[K9/D3]`, `[S9]`, `[A4]`, `[N12-DATE]` (`tests/e2e/50-money-and-errors.e2e.ts`), все красные и в `known-defects.json` | +| §4 | Приёмка `antigravity/fix-reject-device-shifts`, `codex/fix-format-baseline`, `codex/fix-live-database` — таблицы ниже | +| — | Синтетическая ставка 123,45 → 123,00 ₽/ч: расчёт округляет строку до рубля (`rounding_unit_minor`), тест не должен зависеть от округления (выяснилось на fix-live-database) | + +## Круг 3 (TASK-GROK-3) + +Ветка продолжена поверх `ba59a48` без переписывания истории (без rebase на `b0243d5`, без force-push). + +| Пункт | Что сделано | +| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| п.1 | `scripts/test/accept-branch.mjs <ветка>…`: worktree на ветку в `../krona-staff-acceptance/` + оверлей текущего стенда (`tests/e2e/**`, `scripts/test/**`), `npm run check` без переменных БД, **каждый** `tests/integration/*.test.ts` отдельно на свежей мигрированной БД PG17 (красный — по строке `Test Files`, ловит ошибки `beforeAll/afterAll`; файл, где всё пропущено, — отдельный вердикт `SKIP`), e2e и разница с основой по ID, попарный `git merge-tree --write-tree` (все ветки между собой и с основой), итог в markdown. Логи — `tests/e2e/evidence/accept/<время>/` (пароли в URL заменены на `***`) | +| п.1 | Готовые роли: если заданы `E2E_MIGRATOR_URL` и `E2E_APP_URL`, `global-setup` и `accept-branch` роли не создают и пароли не меняют (БД — из URL, сброс откатом всех миграций). По умолчанию как раньше. `provision-pg.mjs --roles keep` пересоздаёт только БД: `accept-branch` создаёт роли один раз на прогон | +| п.1 | Предохранитель изолированной БД для `live-postgres-remediation.test.ts` (Codex): в admin-режиме интеграционная БД одноразовая (`krona_staff_audit_acc_it`), `krona_staff_test_guard` со случайным маркером, `DATABASE_TEST_ISOLATED=true` — без этого файл пропускается целиком | +| п.2 | «Форматирование без поведения» (для веток с `format` в имени и `--format-check <ветка>`): версия из merge-base → prettier (конфиг основы, API из `node_modules`) → сравнение с веткой; для лишних строк — сколько из них есть в других переданных ветках | +| п.3 | Прогон по всем открытым `codex/*`, `antigravity/*` — ниже | +| п.4 | Храповик **не тронут**: `origin/codex/bootstrap-platform-foundation` = `b0243d5` (только docs поверх `f5ac179`); `git merge-base --is-ancestor origin/<ветка> origin/codex/bootstrap-platform-foundation` — `NOT merged` для fix-live-database, fix-reject-device-shifts, fix-sync-c0-bootstrap, fix-format-baseline | +| п.5 | `[PILOT-REHOME-SEED]` (зелёный) и `[PILOT-REHOME]` (красный, в `known-defects.json`) — `tests/e2e/60-pilot-rehome.e2e.ts` | +| — | `run-e2e.mjs`: проверка секретов в `evidence/` больше не падает `EISDIR` на подкаталоге `accept/` | + +### п.1: ожидания владельца на SHA из задания + +Команда: `node scripts/test/accept-branch.mjs 1df2019 antigravity/fix-sync-c0-bootstrap codex/fix-format-baseline` +(16:04 NOVT; `1df2019` передан SHA, потому что ветка Antigravity к этому времени была перезаписана +на `6c5bf36`). Полный вывод — [`report-evidence/round3-accept-owner-shas.md`](report-evidence/round3-accept-owner-shas.md). + +| Ожидание владельца | Фактический вывод | +| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| sync-rejection.test.ts на 1df2019 FAIL `column "created_by" … does not exist` | `FAIL \| 1 failed (1) \| 2 skipped (2) \| error: column "created_by" of relation "staff_employees" does not exist` | +| sync-bootstrap.test.ts на 8baad55 FAIL `Called end on pool more than once` при `Tests 2 passed` | `FAIL \| 1 failed (1) \| 2 passed (2) \| Error: Called end on pool more than once` | +| SYNC-C0 зелёный на 8baad55 | e2e «стало зелёным»: `SYNC-C0` | +| AG reject + AG c0 — конфликт в src/domain/sync/service.ts | `1df2019 \| origin/antigravity/fix-sync-c0-bootstrap \| КОНФЛИКТ \| src/domain/sync/service.ts` | +| Codex format + AG c0 — конфликт | `origin/antigravity/fix-sync-c0-bootstrap \| origin/codex/fix-format-baseline \| КОНФЛИКТ \| src/domain/sync/service.ts` | +| Codex format + AG reject — чисто | `1df2019 \| origin/codex/fix-format-baseline \| чисто` | + +Все шесть совпали. На `6c5bf36` (первый прогон по именам веток, +[`round3-accept-owner-branches-first.md`](report-evidence/round3-accept-owner-branches-first.md)) +`sync-rejection.test.ts` уже зелёный (`1 passed (1) | 2 passed (2)`). + +### п.2: форматирование без поведения + +Команда: `node scripts/test/accept-branch.mjs --format-check tmp/prettier-only-f5ac179 --format-check codex/fix-format-baseline` +([`round3-format-proof.md`](report-evidence/round3-format-proof.md)). +`tmp/prettier-only-f5ac179` (`c552d38`) — локальная синтетическая ветка: `prettier --write .` поверх +`f5ac179`, 24 файла; **не запушена**. + +| Ветка | Результат | +| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tmp/prettier-only-f5ac179` (c552d38) | изменено файлов: 24 — ТОЛЬКО ФОРМАТИРОВАНИЕ | +| `codex/fix-format-baseline` (513cdff, прежняя голова) | ЕСТЬ ИЗМЕНЕНИЯ СВЕРХ PRETTIER: `src/domain/sync/service.ts` 232 строки, `tests/unit/sync.test.ts` 130 строк; в `antigravity/fix-reject-device-shifts` есть 232/232 и 130/130 этих строк | +| `codex/fix-format-baseline` (a05777c, текущая, 16:09 NOVT) | изменено файлов: 19 — ТОЛЬКО ФОРМАТИРОВАНИЕ | + +### п.3: все открытые ветки + +Команда: `node scripts/test/accept-branch.mjs <все origin/codex/* и origin/antigravity/* кроме основы>` +после `git fetch` в 16:16 NOVT, стенд `6f408bc`. Полный вывод (интеграционные тесты по файлам, 55 пар +merge-tree) — [`report-evidence/round3-accept-all-open.md`](report-evidence/round3-accept-all-open.md). +Основа `b0243d5`: check OK, integration 3/4 (FAIL tenant-isolation), e2e 46 red / 5 green. + +| Ветка | SHA | Влита в основу | check | integration | e2e red/green | Стало зелёным | Стало красным | +| ------------------------------------ | ------- | -------------------------- | ----- | --------------------------------------------------------- | ------------- | ------------------------------- | -------------------------------------------------------------------------- | +| antigravity/fix-reject-device-shifts | b283736 | нет (+1) | OK | 4/5, FAIL tenant-isolation | 44/7 | K13/SYNC-A, K13/SYNC-B | — | +| antigravity/fix-sync-c0-bootstrap | 24cc18d | нет (+3, включает b283736) | OK | 5/6, FAIL tenant-isolation | 43/8 | K13/SYNC-A, K13/SYNC-B, SYNC-C0 | — | +| codex/fix-format-baseline | a05777c | нет (+1, от f5ac179) | OK | 3/4, FAIL tenant-isolation | 46/5 | — | — | +| codex/fix-live-database | 1573860 | нет (+2, на a05777c) | OK | 4/5, FAIL tenant-isolation (live-postgres-remediation OK) | 44/7 | K3/N2, K4/N1 | — | +| antigravity/employee-notifications | 3e20544 | да (предок) | OK | 3/4 | 49/2 | — | HARNESS, PILOT-REHOME-SEED, SYNC-L (старый код: нет таблиц/маршрутов sync) | +| antigravity/offline-sync-backend | 5ee7517 | да (предок) | OK | 3/4 | 46/5 | — | — | +| codex/finance-calendar-dashboard | c08a7e6 | да (предок) | OK | 4/4 | 48/3 | A4, K4/N1 | CTRL, HARNESS, PILOT-REHOME-SEED, SYNC-L (старый код) | +| codex/frontend-library | 331e490 | да (предок) | OK | 3/4 | 46/5 | — | — | +| codex/security-finance-production | f74aa6d | да (предок) | OK | 4/4 | 49/2 | — | HARNESS, PILOT-REHOME-SEED, SYNC-L (старый код) | +| codex/server-web-production | 2f6b367 | да (предок) | OK | 4/4 | 49/2 | K4/N1 | CTRL, HARNESS, PILOT-REHOME-SEED, SYNC-L (старый код) | + +- `git merge-tree`: **конфликтов нет** ни в одной из 55 пар (Antigravity выстроил цепочку reject → c0, + Codex пересобрал format без sync). Шесть веток — предки основы (уже влиты), для них «стало + красным» означает только «код старше основы». +- `tenant-isolation.test.ts` красный на основе и всех ветках от `f5ac179` + (`error: permission denied for table staff_employees`) — это N5, исправление есть в + `grok/e2e-harness` (`ba59a48`), в основу ещё не влито. + +### Сборка «как будет после слияния» (локально, не запушено) + +`tmp/combined-round3` = `b0243d5` + merge `codex/fix-live-database` (1573860) + merge +`antigravity/fix-sync-c0-bootstrap` (24cc18d) — без конфликтов; `tmp/combined-round3-grok` = то же + +merge `grok/e2e-harness`. Вывод — [`round3-combined-merge.md`](report-evidence/round3-combined-merge.md). + +| Сборка | check | integration | e2e red/green | Стало зелёным | +| ------------------------------ | -------------------------- | -------------------------- | ------------- | --------------------------------------------- | +| combined-round3 (48128c8) | OK (199 passed, 6 skipped) | 6/7, FAIL tenant-isolation | 41/10 | K13/SYNC-A, K13/SYNC-B, K3/N2, K4/N1, SYNC-C0 | +| combined-round3-grok (e94415a) | OK (199 passed, 6 skipped) | **7/7** | 41/10 | то же | + +Храповик на `combined-round3-grok`: `[run-e2e] FAIL: K13/SYNC-A, K13/SYNC-B, K3/N2, K4/N1, SYNC-C0 — +дефект исправлен, обновите known-defects.json` — ровно список из TASK-GROK-3 п.4. Удалю их отдельным +коммитом, когда ветки действительно окажутся в основе. + +### п.5: PILOT-REHOME + +- `[PILOT-REHOME-SEED]` — **зелёный**: 27 таблиц с `tenant_id` из `information_schema.columns`, в каждую + 2+ строки заглушки `01J00000000000000000000000` и постороннего случайного арендатора (SQL мигратором, + `app.tenant_id`/`app.user_id` в транзакции); таблица без сида → красный со списком. +- `[PILOT-REHOME]` — **красный** (в `known-defects.json`): `scripts/rehome-tenant.mjs отсутствует +(TASK-CODEX-2 п.2.2): … Засеяно таблиц: 27, ledger SUM(amount_minor)=…`. +- Сценарий проверен временным эталоном скрипта (не коммитился, удалён): с ним зелёный; мутации + «без записи аудита», «без отказа на повтор», «пропущена таблица», «dry-run пишет» — красные + ([`report-evidence/round3-pilot-rehome.txt`](report-evidence/round3-pilot-rehome.txt)). +- Контракт, который проверяет сценарий, — `NOTES-FOR-EXECUTORS.md`, C6. + +## Как запустить + +```sh +export E2E_PG_ADMIN_URL=postgres://:@127.0.0.1:55433/postgres # только создание ролей и БД +E2E_MIGRATIONS=roundtrip node scripts/test/run-e2e.mjs # храповик known-defects (как в CI) +node scripts/test/run-e2e.mjs --no-known-defects # строго: любой красный = exit 1 +./scripts/test/ci-local.sh # локальный эквивалент verify/format/e2e (bash) +node scripts/test/accept-branch.mjs <ветка> [<ветка>…] # приёмка веток (worktree, check, integration, e2e, merge-tree) +# роли уже есть (общий кластер): E2E_MIGRATOR_URL=… E2E_APP_URL=… — роли и пароли не трогаются +``` + +## Таблица по ID (основная ветка f5ac179 + этот стенд) + +Команда: `./scripts/test/ci-local.sh` (внутри `E2E_MIGRATIONS=roundtrip node scripts/test/run-e2e.mjs`), +2026-09-26 ~16:25 (UTC+7), PostgreSQL 17.11, стенд круга 3: + +```text +::: [check] with_db_env npm run check + Test Files 20 passed (20) + Tests 196 passed (196) +::: [format-check] FAIL exit=1 # 23 файла базовой линии основы, ни одного из tests/e2e, scripts/test, .forgejo + Test Files 6 failed | 1 passed (7) + Tests 46 failed | 10 passed (56) +[run-e2e] OK: стенд работает, все красные сценарии — известные открытые дефекты +``` + +Круг 3 добавил `[PILOT-REHOME-SEED]` (GREEN) и `[PILOT-REHOME]` (RED, `scripts/rehome-tenant.mjs +отсутствует (TASK-CODEX-2 п.2.2)`); остальные строки таблицы — без изменений со второго круга. + +| ID владельца / мой | Цвет | Фактический вывод (первая строка assert) | +| ---------------------------- | ----- | ------------------------------------------------------------------------------------------------- | +| HARNESS (×5), CTRL (×2) | GREEN | 7/7 passed — стенд и позитивные контроли | +| K1/F2 | RED | `{ portal: 200, payroll: 200, write: 201 }` вместо 401 | +| K1/SYNC-I | RED | анонимная регистрация устройства `HTTP 201` | +| K1/PILOT-1 | RED | 14 подстановок без `:?` (первая `STANDALONE_MANAGER_TOKEN`) | +| K2/PILOT-2 | RED | `NODE_ENV=development` у сервиса `app` | +| K3/N2 | RED | подтверждение смены `HTTP 500 code 23502 null value in column "occurred_at" of "staff_outbox"` | +| K4/N1 | RED | `HTTP 500 code 42P08 inconsistent types deduced for parameter $2` | +| K5/N4 | RED | блокирован K3 (500 на подтверждении) | +| K6/D5 | RED | блокирован K3 (500 на подтверждении) | +| K7/D2 | RED | `afterFirst: 22200, afterSecond: 22200` вместо `33300, 37700` | +| K8/D1 | RED | блокирован K3 (500 на подтверждении) | +| K9/D3 | RED | `params.shift_rate_minor` не сохранён (`HTTP 200`, в `params` нет ключа) | +| K10/F3 | RED | `rawTokenInCookie: true` | +| K11/F4 | RED | все попытки `401`, 429 нет | +| K12/N3 | RED | подписка на `https://127.0.0.1:` `HTTP 201`, 1 входящее соединение | +| K13/SYNC-A, K13/SYNC-B | RED | `upsert_work_session` / `upsert_timesheet_entry` → `status: applied` | +| K17/OWN-10, K17/OWN-11 | RED | `404 Route GET .../terminal/employees/:id/enrollment` / `PUT .../biometric-consent` (нет API, C3) | +| K19/OWN-07 | RED | `404 Route POST /access-grants` (нет API, C1/C2) | +| D6/OWN-01, D6/OWN-02 | RED | `404 Route POST /access-grants` (нет API, C1) | +| OWN-03..06, OWN-08, OWN-09 | RED | `404 Route POST /access-grants` (нет API, C1/C2) | +| S2/SYNC-J | RED | отметка 2001 года → `status: applied` | +| S3/SYNC-G | RED | `HTTP 500 code 22007` на весь пакет | +| S4/SYNC-E | RED | отметка уволенного → `applied` | +| S4/SYNC-F | RED | `{ ownOrgUnit: null, foreign: "applied" }` | +| S5/SYNC-C | RED | `HTTP 500 code 42703 column "status" does not exist` (блокирован SYNC-C0) | +| S5/SYNC-D | RED | изменение из портала не видно в инкрементальном pull | +| S8/SYNC-H | RED | регистрация устройства по cookie с чужим Origin `HTTP 201` | +| S9 | RED | удаление точки с устройством: `23502 null value in column "tenant_id"` | +| A4 | RED | 2 тела 500 с SQLSTATE/текстом PostgreSQL (`POST /employees` 42P08, `month=2026-13` 22008) | +| N9 | RED | чужой push-endpoint перепривязан `HTTP 201` | +| N10 | RED | терминал: `{ professions: 200, units: 200 }` | +| N11 | RED | импорт с неверным kind `HTTP 500 code 23514` | +| N12 | RED | лишнее поле и нереальная сумма → `HTTP 200` | +| N12-DATE | RED | `month=2026-13` → `HTTP 500 code 22008`; `2026-02-30` уже 400 | +| F7 | RED | повтор Idempotency-Key создал второе событие | +| F10 | RED | мутация по cookie без Origin `HTTP 201` | +| SYNC-C0 (нет ID у владельца) | RED | bootstrap pull `HTTP 500 code 42703 column "status" does not exist` | +| SYNC-K (нет ID у владельца) | RED | ack курсора из будущего `HTTP 200` | +| SYNC-L | GREEN | отозванное устройство получает 403 (позитивный контроль) | +| PILOT-REHOME-SEED | GREEN | 27 таблиц с `tenant_id` засеяны (заглушка + посторонний арендатор) | +| PILOT-REHOME | RED | `scripts/rehome-tenant.mjs отсутствует (TASK-CODEX-2 п.2.2)` | +| PILOT-3 | GREEN | у `app` нет учётных данных мигратора | +| N5 (tenant-isolation) | GREEN | в `npm run check` с тестовой БД: `Test Files 20 passed (20)`, `Tests 196 passed (196)` | +| N13 (скрипт, не vitest) | — | не воспроизведено: 0 из 30 попыток при 10/25/50 (обход K3 в одноразовой БД), см. ниже | + +Сверх таблицы TASK-GROK-2 §2 (по тексту AUDIT.md): K1/SYNC-I (анонимный доступ без токенов на +`/sync`), K1/PILOT-1 (подстановки без `:?` названы в K1), K2/PILOT-2 (development в пилоте — суть K2). + +## Приёмка веток исполнителей (TASK-GROK-2 §4) + +Способ: `git worktree add --detach origin/<ветка>`, поверх — `tests/e2e/` и `scripts/test/` +из этой ветки (в ветки исполнителей ничего не коммитилось), затем +`E2E_MIGRATIONS=roundtrip E2E_DATABASE_NAME=krona_staff_acc node scripts/test/run-e2e.mjs`. +«Было» — основная ветка `f5ac179` с тем же стендом (45 red / 9 green, храповик OK). Полные таблицы — +`tests/e2e/report-evidence/acceptance-*.txt`. Ни одна ветка не влита в основную +(`git merge-base --is-ancestor` → нет), поэтому `known-defects.json` не сокращён. + +### `codex/fix-live-database` @ ea68e12, перепроверено на b289dd6 + +```text +== roundtrip OK: rolledBack=11 reapplied=11 + Tests 41 failed | 13 passed (54) +| K13/SYNC-A | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +| K13/SYNC-B | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +| K3/N2 | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +| K4/N1 | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +``` + +| ID | Было | Стало | Доказательство | +| ---------- | ---- | ----- | ------------------------------------------------------------------------------------------------------------ | +| K3/N2 | RED | GREEN | было: подтверждение смены `500 23502 occurred_at`; стало: passed (8 ч × 123,00 ₽ = 98400 в ledger) | +| K4/N1 | RED | GREEN | было: `500 42P08`; стало: passed (201, сотрудник в БД) | +| K13/SYNC-A | RED | GREEN | было: `upsert_work_session → applied`; стало: passed (см. находку по fd1ec3d) | +| K13/SYNC-B | RED | GREEN | было: `upsert_timesheet_entry → applied`; стало: passed | +| K5/N4 | RED | RED | было: блокирован K3; стало: `смена должна быть начислена ровно один раз: expected 196800n to be 98400n` | +| K6/D5 | RED | RED | было: блокирован K3; стало: `после отклонения оплаченной смены должно быть сторно: expected 98400n to be 0n` | +| K8/D1 | RED | RED | было: блокирован K3; стало: `C4 verify: HTTP 404 Route GET .../payroll/runs/:id/verify not found` | +| A4 | RED | RED | было: 2 утечки; стало: 1 — `POST /payroll/calculate month=2026-13` → `code=22008` | +| F14 | RED | GREEN | `prettier --check .` → `All matched files use Prettier code style!` (без оверлея evidence) | + +Ветку обновили force-push во время приёмки (`ea68e12` → `b289dd6`, изменён только +`tests/integration/live-postgres-remediation.test.ts`); повторный прогон на `b289dd6` дал те же +41 red / 13 green и ту же таблицу. + +Вердикт: K3 и K4 исправлены (подтверждено на живой PostgreSQL 17, миграции roundtrip). После влития +убрать `K3/N2`, `K4/N1` из `known-defects.json`. Цепочка расчёта теперь доходит до денежных дефектов: +K5 и K6 подтверждены по существу. + +### `codex/fix-format-baseline` @ fd1ec3d + +```text + Tests 43 failed | 11 passed (54) +| K13/SYNC-A | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +| K13/SYNC-B | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +$ node node_modules/prettier/bin/prettier.cjs --check . +All matched files use Prettier code style! +``` + +| ID | Было | Стало | Доказательство | +| ---------- | -------------- | ----- | ------------------------------------------------- | +| F14 | RED (23 файла) | GREEN | `All matched files use Prettier code style!` | +| K13/SYNC-A | RED | GREEN | было: `applied`; стало: passed | +| K13/SYNC-B | RED | GREEN | было: `applied`; стало: passed | +| остальные | без изменений | — | compare по `e2e-results.json`: других отличий нет | + +**Находка:** «style»-коммит `fd1ec3d` содержит функциональное исправление Antigravity. +После нормализации prettier `src/domain/sync/service.ts` отличается от `331e490` на 255 строк diff +и совпадает с `antigravity/fix-reject-device-shifts` (0 строк); `tests/unit/sync.test.ts` — 139 и 0. +Изменение поведения скрыто в коммите форматирования — смешение тем (AGENTS.md). + +### `antigravity/fix-reject-device-shifts` @ 795bba0, перепроверено на 1df2019 + +```text + Tests 43 failed | 11 passed (54) +| K13/SYNC-A | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +| K13/SYNC-B | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +``` + +| ID | Было | Стало | Доказательство | +| ---------- | ---- | ----- | ------------------------------------------------------- | +| K13/SYNC-A | RED | GREEN | было: `upsert_work_session → applied`; стало: passed | +| K13/SYNC-B | RED | GREEN | было: `upsert_timesheet_entry → applied`; стало: passed | +| остальные | — | — | других отличий нет | + +Ветку перебазировали на `f5ac179` во время приёмки (`795bba0` → `1df2019`); повторный прогон +на `1df2019`: те же 43 red / 11 green, отличия только K13/SYNC-A/B. + +Вердикт: K13 исправлен. Убрать `K13/SYNC-A`, +`K13/SYNC-B` отдельным коммитом после влития (и после исправления очистки в +`sync-rejection.test.ts`, TASK-GROK-2 §4.1). + +## Windows + +- Запуск стенда переписан без `npx` и `node_modules/.bin` (раньше `spawn('npx')` и + `spawn('.../.bin/tsx')` падали на Windows с `ENOENT`). Проверка на Linux: + `tests/e2e/harness/node-commands.test.ts` — 8 тестов: команды начинаются с `process.execPath`, + без `.cmd`/`.bin`/`npx`, файлы существуют; под `win32` дочерний процесс получает + `SystemRoot`/`TEMP`, секреты родителя не наследуются. +- **Реальный запуск на Windows 11 — не проверено**: Windows-раннера у git.ochenstarik.ru нет. + В CI добавлена задача `windows` (`runs-on: windows`), включаемая переменной + `KRONA_WINDOWS_RUNNER=true`; для полного e2e нужен секрет `WINDOWS_E2E_PG_ADMIN_URL`. +- `scripts/test/ci-local.sh` — bash, на Windows не предназначен (под Windows запускать + `node scripts/test/run-e2e.mjs`). + +## Evidence вне git + +```text +$ E2E_MIGRATIONS=roundtrip node scripts/test/run-e2e.mjs # (повторный прогон) +[run-e2e] OK: стенд работает, все красные сценарии — известные открытые дефекты +$ git status --porcelain + # (пусто) +``` + +## N5 (tenant-isolation) + +До (`778ba2d`, очистка ролью приложения) — [`report-evidence/n5-before.txt`](report-evidence/n5-before.txt): +`error: permission denied for table staff_employees`, `exit=1`. После (очистка ролью мигратора с +`app.tenant_id`) — [`report-evidence/n5-after.txt`](report-evidence/n5-after.txt): 2 прогона, `exit=0`. +На `f5ac179`: `npm run check` с тестовой БД — `Test Files 20 passed (20)`, `Tests 196 passed (196)`. + +## CI (`.forgejo/workflows/ci.yml`) + +- `verify`: `postgres:17-alpine@sha256:…`, роли как в pilot (мигратор NOSUPERUSER, app NOBYPASSRLS), + миграции `up → откат всех 11 → up → up`, `npm run check` с тестовой БД, `npm audit`. +- `format`: отдельно (F14 красный до влития `codex/fix-format-baseline`). +- `e2e`: PostgreSQL 17, `E2E_MIGRATIONS=roundtrip`, храповик; evidence — артефакт `e2e-evidence` + (`actions/upload-artifact@ea165f8… # v4.6.2`, Forgejo 9.0.3). +- `windows`: выключена без раннера (см. выше). `docker-build`: метка `docker-host`, `needs: verify`, только push. +- Все actions закреплены SHA (F13), SHA совпадают на github.com и code.forgejo.org. + +Механизм красных сценариев — храповик `known-defects.json`, а не «allowed to fail»: сломанный стенд, +новый красный ID или исправленный, но не вычеркнутый ID делают задачу `e2e` красной. + +Валидация: `actionlint -config-file actionlint.yaml .forgejo/workflows/ci.yml` (1.7.12, метки +`docker`, `docker-host`, `windows`) → 0 замечаний. Локально `./scripts/test/ci-local.sh`: + +```text +::: [migrate-roundtrip] OK (== roundtrip OK: rolledBack=11 reapplied=11) +::: [check] OK (Test Files 20 passed (20), Tests 196 passed (196)) +::: [audit] OK (found 0 vulnerabilities) +::: [format-check] FAIL (Code style issues found in 23 files — F14) +::: [e2e] OK (Tests 45 failed | 9 passed (54), храповик OK) +``` + +## N13 (гонка первого расчёта) + +`node scripts/test/n13-race.mjs` (10/25/50 параллельных первых расчётов × 5 попыток) — +[`report-evidence/n13-race.txt`](report-evidence/n13-race.txt): как есть 15/15 → 500 (K3); с обходом K3 +в одноразовой БД — 0 некорректных из 30 (с подтверждённой сменой и без). Не воспроизведено, как и у +владельца; вероятная причина (гипотеза по коду) — upsert `ensureDefaultPayRules` сериализует расчёты +тенанта. Прогон до перебазирования, на `778ba2d`. + +## Не проверено + +- Реальный Forgejo-раннер (workflow выполнялся только локальным эквивалентом), `docker build`. +- Запуск на Windows 11 (раннера нет); Windows-only: DPAPI, ACL каталога данных, установщик, WinUI, + named pipe — см. тесты Codex в `apps/KronaStaffTerminal.Tests`. +- OWN-01..11, K8 (C4) и K9 (C5) проверяют предполагаемые контракты из NOTES. +- K5/N4, K6/D5, K8/D1 на основной ветке блокированы K3; по существу проверены только на + `codex/fix-live-database`. +- S9 проверен удалением SQL-запросом мигратора (публичного API удаления точки нет). +- Причина невоспроизводимости N13 — гипотеза по коду. +- Круг 3: `scripts/rehome-tenant.mjs` не существует — `[PILOT-REHOME]` проверен только временным + эталоном; контракт C6 — предположение. `accept-branch` в CI не запускается (разовый инструмент + владельца/приёмщика). Режим `E2E_MIGRATOR_URL/E2E_APP_URL` проверен на том же локальном кластере + (роли созданы заранее `provision-pg`), на чужом кластере — не проверено. diff --git a/tests/e2e/harness/client.ts b/tests/e2e/harness/client.ts new file mode 100644 index 0000000..2cded98 --- /dev/null +++ b/tests/e2e/harness/client.ts @@ -0,0 +1,94 @@ +// HTTP-клиент e2e. Тело ответа типизировано как `any` намеренно: тесты проверяют +// внешний контракт JSON, а не типы src/. Правила no-unsafe-* отключены только здесь +// и в файлах сценариев. +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */ +import pg from 'pg'; +import { inject } from 'vitest'; +import type { E2EContext } from './context.js'; + +export interface ApiResponse { + status: number; + body: any; + text: string; + headers: Headers; +} + +export interface RequestOptions { + token?: string | undefined; + body?: unknown; + headers?: Record; + base?: 'secured' | 'open' | 'ratelimit'; +} + +export function ctx(): E2EContext { + return inject('e2e'); +} + +export async function api( + method: string, + path: string, + options: RequestOptions = {}, +): Promise { + const context = ctx(); + const base = + options.base === 'open' + ? context.openBaseUrl + : options.base === 'ratelimit' + ? context.rateLimitBaseUrl + : context.baseUrl; + const headers: Record = { ...(options.headers ?? {}) }; + if (options.token) headers.authorization = `Bearer ${options.token}`; + let payload: string | undefined; + if (options.body !== undefined) { + headers['content-type'] = 'application/json'; + payload = JSON.stringify(options.body); + } + const url = path.startsWith('/api/') || path.startsWith('/staff') ? path : `/api/v1/staff${path}`; + const response = await fetch(`${base}${url}`, { + method, + headers, + ...(payload === undefined ? {} : { body: payload }), + redirect: 'manual', + }); + const text = await response.text(); + let body: any = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = null; + } + return { status: response.status, body, text, headers: response.headers }; +} + +/** Краткое описание ответа для сообщений assert (без токенов). */ +export function describeResponse(response: ApiResponse): string { + const snippet = response.text.length > 300 ? `${response.text.slice(0, 300)}…` : response.text; + return `HTTP ${response.status} ${snippet}`; +} + +/** Запрос к БД ролью приложения внутри tenant-транзакции (RLS действует). */ +export async function dbQuery>( + sql: string, + params: unknown[] = [], +): Promise { + 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)", [tenantId]); + const result = await client.query(sql, params); + await client.query('COMMIT'); + return result.rows; + } finally { + await client.end(); + } +} diff --git a/tests/e2e/harness/context.ts b/tests/e2e/harness/context.ts new file mode 100644 index 0000000..05aa696 --- /dev/null +++ b/tests/e2e/harness/context.ts @@ -0,0 +1,43 @@ +import { randomBytes } from 'node:crypto'; + +export interface E2EContext { + baseUrl: string; + openBaseUrl: string; + rateLimitBaseUrl: string; + managerToken: string; + terminalToken: string; + tenantId: string; + actorId: string; + terminalActorId: string; + appDatabaseUrl: string; + migrationDatabaseUrl: string; + /** Админ (владелец кластера) в БД стенда; null, если не передан. Только для PILOT-REHOME. */ + adminDatabaseUrl: string | null; +} + +declare module 'vitest' { + export interface ProvidedContext { + e2e: E2EContext; + } +} + +const crockford = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + +/** Случайный ULID (время + crypto.randomBytes), чтобы не зависеть от src/. */ +export function randomUlid(): string { + let time = Date.now(); + let timePart = ''; + for (let i = 0; i < 10; i += 1) { + timePart = crockford[time % 32]! + timePart; + time = Math.floor(time / 32); + } + const bytes = randomBytes(16); + let randomPart = ''; + for (let i = 0; i < 16; i += 1) randomPart += crockford[bytes[i]! % 32]!; + return timePart + randomPart; +} + +/** Синтетическая метка для данных тестов: очевидно не реальные люди/суммы. */ +export function synthetic(label: string): string { + return `E2E-${label}-${randomBytes(3).toString('hex')}`; +} diff --git a/tests/e2e/harness/fixtures.ts b/tests/e2e/harness/fixtures.ts new file mode 100644 index 0000000..a7d8bd7 --- /dev/null +++ b/tests/e2e/harness/fixtures.ts @@ -0,0 +1,159 @@ +// Подготовка данных через публичный API. Везде только синтетические данные. +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { expect } from 'vitest'; +import { api, ctx, dbQuery, describeResponse } from './client.js'; +import { randomUlid, synthetic } from './context.js'; + +export interface OrgUnit { + id: string; + code: string; +} + +export interface Employee { + id: string; + external_ref: string; + personnel_number: string; +} + +/** Создаёт торговые точки через импорт (единственный публичный способ в текущем API). */ +export async function createOrgUnits(count: number): Promise { + const units = Array.from({ length: count }, () => { + const code = `e2e-${randomUlid().slice(-10).toLowerCase()}`; + return { code, name: synthetic('Точка') }; + }); + const response = await api('POST', '/imports/owner-workbook', { + token: ctx().managerToken, + body: { source_name: synthetic('import-units'), units }, + }); + expect(response.status, describeResponse(response)).toBeLessThan(300); + const rows = await dbQuery<{ id: string; code: string }>( + 'SELECT id, code FROM staff_org_units WHERE code = ANY($1::text[])', + [units.map((unit) => unit.code)], + ); + return units.map((unit) => ({ + code: unit.code, + id: rows.find((row) => row.code === unit.code)!.id, + })); +} + +/** + * Создаёт сотрудников через импорт книги владельца: POST /employees на реальной БД + * сейчас падает (N1), а сценарии, не относящиеся к N1, не должны зависеть от этого дефекта. + */ +export async function importEmployees(count: number): Promise { + const employees = Array.from({ length: count }, () => { + const suffix = randomUlid().slice(-8); + return { + external_ref: `e2e:${suffix}`, + personnel_number: `E2E-${suffix}`, + display_name: synthetic('Сотрудник'), + }; + }); + const response = await api('POST', '/imports/owner-workbook', { + token: ctx().managerToken, + body: { source_name: synthetic('import-employees'), employees }, + }); + expect(response.status, describeResponse(response)).toBeLessThan(300); + const rows = await dbQuery<{ id: string; external_ref: string }>( + 'SELECT id, external_ref FROM staff_employees WHERE external_ref = ANY($1::text[])', + [employees.map((employee) => employee.external_ref)], + ); + return employees.map((employee) => ({ + ...employee, + id: rows.find((row) => row.external_ref === employee.external_ref)!.id, + })); +} + +export async function issueEmployeeToken(employeeId: string): Promise { + const response = await api('POST', `/employees/${employeeId}/access-token`, { + token: ctx().managerToken, + }); + expect(response.status, describeResponse(response)).toBe(200); + return response.body.access_token as string; +} + +export async function clock( + employeeId: string, + eventType: 'check_in' | 'check_out', + occurredAt: Date, + orgUnitId?: string, +) { + return api('POST', '/attendance/events', { + token: ctx().terminalToken, + body: { + employee_id: employeeId, + event_type: eventType, + occurred_at: occurredAt.toISOString(), + source_event_id: `e2e-${randomUlid()}`, + ...(orgUnitId ? { org_unit_id: orgUnitId } : {}), + }, + }); +} + +/** Дата (UTC) N дней назад в полдень: всегда в окне терминала (<=180 дней) и не в будущем. */ +export function daysAgo(days: number, hourUtc = 9): Date { + const date = new Date(); + date.setUTCDate(date.getUTCDate() - days); + date.setUTCHours(hourUtc, 0, 0, 0); + return date; +} + +export function isoDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +/** Отработанная смена: check_in/check_out терминалом + сверка; возвращает id смены. */ +export async function workedShift( + employeeId: string, + orgUnitId: string, + day: Date, + hours = 8, +): Promise { + const start = new Date(day); + const end = new Date(start.getTime() + hours * 3_600_000); + const inResponse = await clock(employeeId, 'check_in', start, orgUnitId); + expect(inResponse.status, describeResponse(inResponse)).toBe(201); + const outResponse = await clock(employeeId, 'check_out', end, orgUnitId); + expect(outResponse.status, describeResponse(outResponse)).toBe(201); + const reconcile = await api('POST', '/work-sessions/reconcile', { + token: ctx().managerToken, + body: { date_from: isoDate(start), date_to: isoDate(start) }, + }); + expect(reconcile.status, describeResponse(reconcile)).toBe(200); + const rows = await dbQuery<{ id: string }>( + `SELECT id FROM staff_work_sessions + WHERE employee_id = $1 AND org_unit_id = $2 AND business_date = $3::date`, + [employeeId, orgUnitId, isoDate(start)], + ); + expect(rows.length, 'сверка должна создать смену').toBe(1); + return rows[0]!.id; +} + +export async function hourlyRuleId(): Promise { + const response = await api('GET', '/pay-rules', { token: ctx().managerToken }); + expect(response.status, describeResponse(response)).toBe(200); + const rules = response.body.pay_rules as Array<{ id: string; code: string }>; + const hourly = rules.find((rule) => rule.code === 'hourly') ?? rules[0]; + expect(hourly, 'должно существовать хотя бы одно правило оплаты').toBeTruthy(); + return hourly!.id; +} + +/** + * Синтетическая ставка 123,00 ₽/ч (заведомо не реальная сумма). Кратна рублю: расчёт + * округляет строку до rounding_unit_minor (по умолчанию 100 коп.), и 8 ч × ставка + * должны совпадать с начислением без учёта округления. + */ +export const SYNTHETIC_RATE_MINOR = 12_300; + +export async function assignHourly(employeeId: string, orgUnitId: string | null) { + const response = await api('PUT', `/employees/${employeeId}/pay-assignment`, { + token: ctx().managerToken, + body: { + pay_rule_id: await hourlyRuleId(), + org_unit_id: orgUnitId, + rate_minor: SYNTHETIC_RATE_MINOR, + effective_from: '2026-01-01', + }, + }); + expect(response.status, describeResponse(response)).toBe(200); +} diff --git a/tests/e2e/harness/global-setup.ts b/tests/e2e/harness/global-setup.ts new file mode 100644 index 0000000..7cb6361 --- /dev/null +++ b/tests/e2e/harness/global-setup.ts @@ -0,0 +1,236 @@ +// Глобальная подготовка e2e: реальная PostgreSQL 17, миграции ролью мигратора +// (без SUPERUSER), реальный процесс сервера под ролью приложения, случайные секреты. +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { createWriteStream, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import type { TestProject } from 'vitest/node'; +import { baseEnv, nodeWithTsx } from '../../../scripts/test/node-commands.mjs'; +import { randomUlid, type E2EContext } from './context.js'; + +const repoRoot = path.resolve(import.meta.dirname, '../../..'); +const evidenceDir = path.join(repoRoot, 'tests/e2e/evidence'); + +function parseEnvFile(file: string): Record { + const result: Record = {}; + for (const line of readFileSync(file, 'utf8').split('\n')) { + const index = line.indexOf('='); + if (index > 0) result[line.slice(0, index)] = line.slice(index + 1); + } + return result; +} + +async function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + server.close(() => resolve(port)); + }); + }); +} + +function runNode(args: string[], env: NodeJS.ProcessEnv, label: string) { + const result = spawnSync(process.execPath, args, { cwd: repoRoot, env, encoding: 'utf8' }); + const output = `${result.stdout}${result.stderr}`; + console.log(`[e2e] ${label}:\n${output.trim()}`); + if (result.status !== 0) throw new Error(`${label} failed with exit ${result.status}`); + return output; +} + +function vapidEnv(): Record { + const output = spawnSync(process.execPath, ['scripts/generate-vapid-keys.mjs'], { + cwd: repoRoot, + encoding: 'utf8', + }).stdout; + const env: Record = {}; + for (const line of output.split('\n')) { + const index = line.indexOf('='); + if (index > 0) env[line.slice(0, index)] = line.slice(index + 1); + } + return env; +} + +interface StartedServer { + child: ChildProcess; + baseUrl: string; +} + +async function startServer(name: string, env: Record): Promise { + const port = await freePort(); + const logPath = path.join(evidenceDir, `server-${name}.txt`); + const log = createWriteStream(logPath, { flags: 'w' }); + const server = nodeWithTsx(['src/server.ts']); + const child = spawn(server.command, server.args, { + cwd: repoRoot, + env: { + ...baseEnv(), + NODE_ENV: 'test', + HOST: '127.0.0.1', + PORT: String(port), + LOG_LEVEL: 'info', + STAFF_RUNTIME_MODE: 'standalone', + ...env, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout?.pipe(log); + child.stderr?.pipe(log); + const baseUrl = `http://127.0.0.1:${port}`; + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`server ${name} exited early with ${child.exitCode}; see ${logPath}`); + } + try { + const response = await fetch(`${baseUrl}/api/v1/staff/ready`); + if (response.status === 200) { + console.log( + `[e2e] server ${name} ready at ${baseUrl} (log: ${path.relative(repoRoot, logPath)})`, + ); + return { child, baseUrl }; + } + } catch { + // сервер ещё не слушает порт + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + child.kill('SIGKILL'); + throw new Error(`server ${name} did not become ready; see ${logPath}`); +} + +/** + * Откуда брать БД и роли. + * 1. E2E_MIGRATOR_URL и E2E_APP_URL заданы — роли и пароли уже есть (общий кластер, + * параллельная работа других): стенд их не создаёт и не меняет, БД берётся из URL. + * 2. Иначе (по умолчанию) — bootstrap-админ E2E_PG_ADMIN_URL создаёт роли со + * случайными паролями и пересоздаёт БД E2E_DATABASE_NAME (scripts/test/provision-pg.mjs). + * adminDatabaseUrl — админ в той же БД (нужен только сценарию PILOT-REHOME): + * E2E_DB_ADMIN_URL, либо E2E_PG_ADMIN_URL с именем БД стенда. + */ +function resolveDatabase(): { + migrationUrl: string; + appUrl: string; + adminDatabaseUrl: string | null; +} { + const adminUrl = process.env.E2E_PG_ADMIN_URL; + const withDatabase = (url: string, database: string) => { + const result = new URL(url); + result.pathname = `/${database}`; + return result.toString(); + }; + const presetMigrator = process.env.E2E_MIGRATOR_URL; + const presetApp = process.env.E2E_APP_URL; + if (presetMigrator && presetApp) { + const database = new URL(presetApp).pathname.slice(1); + console.log( + `[e2e] роли заданы E2E_MIGRATOR_URL/E2E_APP_URL: provision пропущен, БД ${database}`, + ); + return { + migrationUrl: presetMigrator, + appUrl: presetApp, + adminDatabaseUrl: + process.env.E2E_DB_ADMIN_URL ?? (adminUrl ? withDatabase(adminUrl, database) : null), + }; + } + if (presetMigrator || presetApp) { + throw new Error('E2E_MIGRATOR_URL и E2E_APP_URL задаются только вместе'); + } + if (!adminUrl) { + throw new Error( + 'E2E_PG_ADMIN_URL не задан: нужен bootstrap-админ PostgreSQL 17 (только для создания ролей и БД) ' + + 'или готовые роли в E2E_MIGRATOR_URL + E2E_APP_URL', + ); + } + const database = process.env.E2E_DATABASE_NAME ?? 'krona_staff_e2e'; + const secretsDir = mkdtempSync(path.join(tmpdir(), 'krona-e2e-')); + const envFile = path.join(secretsDir, 'db.env'); + runNode( + ['scripts/test/provision-pg.mjs', '--out', envFile, '--db', database], + { ...process.env, E2E_PG_ADMIN_URL: adminUrl }, + 'provision', + ); + const dbEnv = parseEnvFile(envFile); + rmSync(secretsDir, { recursive: true, force: true }); + if (!dbEnv.DATABASE_MIGRATION_URL || !dbEnv.DATABASE_URL) { + throw new Error('provision did not produce connection URLs'); + } + return { + migrationUrl: dbEnv.DATABASE_MIGRATION_URL, + appUrl: dbEnv.DATABASE_URL, + adminDatabaseUrl: process.env.E2E_DB_ADMIN_URL ?? withDatabase(adminUrl, database), + }; +} + +export default async function setup(project: TestProject) { + mkdirSync(evidenceDir, { recursive: true }); + const { migrationUrl, appUrl, adminDatabaseUrl } = resolveDatabase(); + const migrationMode = process.env.E2E_MIGRATIONS ?? 'up'; + runNode( + ['scripts/test/migrate-roundtrip.mjs', migrationMode === 'roundtrip' ? 'roundtrip' : 'up'], + { ...baseEnv(), DATABASE_MIGRATION_URL: migrationUrl }, + `migrations (${migrationMode}) as krona_migrator`, + ); + + const tenantId = randomUlid(); + const actorId = randomUlid(); + const terminalActorId = randomUlid(); + const managerToken = randomBytes(32).toString('hex'); + const terminalToken = randomBytes(32).toString('hex'); + const common = { + DATABASE_URL: appUrl, + STANDALONE_TENANT_ID: tenantId, + STANDALONE_ACTOR_ID: actorId, + ...vapidEnv(), + }; + const secured = await startServer('secured', { + ...common, + STANDALONE_ACCESS: 'employee', + STANDALONE_MANAGER_TOKEN: managerToken, + STANDALONE_TERMINAL_TOKEN: terminalToken, + STANDALONE_TERMINAL_ACTOR_ID: terminalActorId, + }); + // Второй процесс без токенов (как корневой compose.yaml), тот же tenant и БД: для F2/K-сценариев. + const open = await startServer('no-tokens', { + ...common, + STANDALONE_ACCESS: 'manager', + }); + + // Третий процесс с собственными токенами только для F4 (перебор входа): если rate limit + // появится, он не должен блокировать вход в остальных сценариях с того же IP. + const rateLimited = await startServer('rate-limit', { + ...common, + STANDALONE_ACCESS: 'employee', + STANDALONE_MANAGER_TOKEN: randomBytes(32).toString('hex'), + STANDALONE_TERMINAL_TOKEN: randomBytes(32).toString('hex'), + }); + + const context: E2EContext = { + baseUrl: secured.baseUrl, + openBaseUrl: open.baseUrl, + rateLimitBaseUrl: rateLimited.baseUrl, + managerToken, + terminalToken, + tenantId, + actorId, + terminalActorId, + appDatabaseUrl: appUrl, + migrationDatabaseUrl: migrationUrl, + adminDatabaseUrl, + }; + project.provide('e2e', context); + + return async () => { + for (const server of [secured, open, rateLimited]) { + server.child.kill('SIGTERM'); + } + await new Promise((resolve) => setTimeout(resolve, 500)); + for (const server of [secured, open, rateLimited]) { + if (server.child.exitCode === null) server.child.kill('SIGKILL'); + } + }; +} diff --git a/tests/e2e/harness/node-commands.test.ts b/tests/e2e/harness/node-commands.test.ts new file mode 100644 index 0000000..5e486ca --- /dev/null +++ b/tests/e2e/harness/node-commands.test.ts @@ -0,0 +1,51 @@ +// Проверяемо на Linux: стенд запускает инструменты так, что под Windows не нужен +// .cmd-обёрточный файл (spawn без shell падает на них с ENOENT). +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { baseEnv, nodeTool, nodeWithTsx, repoRoot } from '../../../scripts/test/node-commands.mjs'; + +const forbidden = /\.cmd\b|[\\/]\.bin[\\/]|^npx$|^npm$/i; + +describe('[HARNESS-WIN] запуск инструментов без .cmd-обёрток', () => { + it.each(['tsx', 'vitest', 'prettier'] as const)('%s: process.execPath + JS-файл', (tool) => { + const command = nodeTool(tool, ['--version']); + expect(command.command).toBe(process.execPath); + const entry = command.args[0]!; + expect(entry).not.toMatch(forbidden); + expect(entry).toMatch(/\.(mjs|cjs|js)$/); + expect(path.isAbsolute(entry)).toBe(true); + expect(existsSync(entry)).toBe(true); + }); + + it('сервер: один процесс node с загрузчиком tsx', () => { + const command = nodeWithTsx(['src/server.ts']); + expect(command.command).toBe(process.execPath); + expect(command.args[0]).toBe('--import'); + expect(command.args[1]).toMatch(/^file:\/\/.*\.mjs$/); + expect(command.args.join(' ')).not.toMatch(forbidden); + expect(command.args.at(-1)).toBe('src/server.ts'); + }); + + it('под win32 дочерний процесс получает SystemRoot/TEMP, а секреты не наследуются', () => { + const env = baseEnv('win32', { + PATH: 'C:\\Windows', + SystemRoot: 'C:\\Windows', + TEMP: 'C:\\Temp', + DATABASE_URL: 'secret-must-not-leak', + }); + expect(env).toMatchObject({ PATH: 'C:\\Windows', SystemRoot: 'C:\\Windows', TEMP: 'C:\\Temp' }); + expect(env).not.toHaveProperty('DATABASE_URL'); + expect(baseEnv('linux', { PATH: '/bin', SystemRoot: 'x' })).toEqual({ PATH: '/bin' }); + }); + + it.each([ + 'scripts/test/run-e2e.mjs', + 'scripts/test/n13-race.mjs', + 'tests/e2e/harness/global-setup.ts', + ])('%s не вызывает npx и node_modules/.bin', (file) => { + const source = readFileSync(path.join(repoRoot, file), 'utf8'); + expect(source).not.toMatch(/spawn(Sync)?\(\s*['"`]npx/); + expect(source).not.toMatch(/node_modules\/\.bin/); + }); +}); diff --git a/tests/e2e/known-defects.json b/tests/e2e/known-defects.json new file mode 100644 index 0000000..7fc1c9d --- /dev/null +++ b/tests/e2e/known-defects.json @@ -0,0 +1,51 @@ +{ + "comment": "ID сценариев, красный цвет которых сейчас ожидаем (открытые дефекты или ещё не реализованные решения владельца). Когда дефект исправлен и все тесты ID зелёные, scripts/test/run-e2e.mjs падает, пока ID не убран отсюда. Добавлять ID сюда можно только вместе с записью в REPORT.md. Ключи — двойные ID «владелец/мой» (docs/audit/2026-09-26/AUDIT.md и TASK-GROK-2 §2), для сценариев без ID владельца — мой ID.", + "defects": { + "K4/N1": "POST /employees: 42P08 inconsistent types for $2 на реальной PostgreSQL", + "K3/N2": "подтверждение смены / расчёт / утверждение: staff_outbox.occurred_at NULL (23502)", + "K12/N3": "SSRF через push-endpoint на внутренние адреса", + "K5/N4": "двойное начисление при пересекающихся расчётах (сейчас блокирован N2)", + "N9": "перепривязка чужого push-endpoint", + "N10": "справочники доступны терминалу", + "N11": "импорт с неверным kind → 500", + "N12": "pay-assignment без additionalProperties:false и верхних границ", + "K1/F2": "standalone без токенов даёт права STANDALONE_ACCESS анониму", + "K10/F3": "cookie = сырой токен, logout не отзывает", + "K11/F4": "нет rate limit на вход", + "F7": "Idempotency-Key не используется", + "F10": "CSRF: мутации по cookie без Origin разрешены", + "K13/SYNC-A": "устройство само подтверждает смену через upsert_work_session", + "K13/SYNC-B": "устройство само утверждает табель через upsert_timesheet_entry", + "SYNC-C0": "начальная загрузка /sync/pull падает 500: column status does not exist (staff_org_units)", + "S5/SYNC-C": "начальная загрузка обрезается на 100 сущностях (сейчас также блокирована SYNC-C0)", + "S5/SYNC-D": "изменения из портала не пишутся в staff_sync_change_log", + "S4/SYNC-E": "отметки уволенных сотрудников принимаются через sync", + "S4/SYNC-F": "sync не сохраняет org_unit_id отметки и принимает чужую точку", + "S3/SYNC-G": "одна битая команда роняет весь пакет (500)", + "S8/SYNC-H": "регистрация устройства по cookie без проверки Origin", + "K1/SYNC-I": "без токенов анонимно регистрируется устройство", + "S2/SYNC-J": "sync принимает отметки вне окна времени", + "SYNC-K": "ack принимает курсор больше выданного сервером", + "K1/PILOT-1": "deploy/pilot/compose.yaml: 14 подстановок без :? (пустые токены/пароли молча проходят)", + "K2/PILOT-2": "deploy/pilot/compose.yaml: app с NODE_ENV=development", + "D6/OWN-01": "решение владельца: роли точки (этап 4 Codex)", + "D6/OWN-02": "решение владельца: пакет отправляет только бухгалтер", + "OWN-03": "решение владельца: уведомления директорам", + "OWN-04": "решение владельца: подписывает только директор", + "OWN-05": "решение владельца: ledger пуст до подписи", + "OWN-06": "решение владельца: подпись → ledger, неизменяемость", + "K19/OWN-07": "решение владельца: изменение смены после отправки возвращает пакет", + "OWN-08": "решение владельца: возврат с комментарием", + "OWN-09": "решение владельца: смена в чужой точке идёт в зарплату той точки", + "K17/OWN-10": "решение владельца: нет согласия на биометрию → регистрация запрещена", + "K17/OWN-11": "решение владельца: отзыв согласия меняет статус для терминала", + "K6/D5": "отклонение смены из подписанной ведомости: 200 без сторно (сейчас блокирован K3 на подтверждении)", + "K7/D2": "повторный импорт книги теряет суммы: две точки в один день → 222,00 вместо 333,00 ₽", + "K8/D1": "подписанная ведомость не воспроизводится после смены ставки; контракта verify нет (C4), сейчас блокирован K3", + "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", + "PILOT-REHOME": "нет scripts/rehome-tenant.mjs (TASK-CODEX-2 п.2.2): перенос заглушки 01J00000000000000000000000 на настоящий tenant не реализован" + } +} diff --git a/tests/e2e/report-evidence/acceptance-antigravity-fix-reject-device-shifts.txt b/tests/e2e/report-evidence/acceptance-antigravity-fix-reject-device-shifts.txt new file mode 100644 index 0000000..6c5b90c --- /dev/null +++ b/tests/e2e/report-evidence/acceptance-antigravity-fix-reject-device-shifts.txt @@ -0,0 +1,27 @@ +# Приёмка ветки antigravity/fix-reject-device-shifts @ 795bba0ce8eb2621a395c6bd794d075609c1a3eb (2026-09-26, UTC+7) +# Код продукта — ветка исполнителя (git worktree --detach origin/antigravity/fix-reject-device-shifts), поверх — tests/e2e и scripts/test из grok/e2e-harness @ 3475133 (без коммитов в ветку исполнителя). +# PostgreSQL 17.11, мигратор NOSUPERUSER, сервер под krona_staff_app. + +$ cd && E2E_MIGRATIONS=roundtrip E2E_DATABASE_NAME=krona_staff_acc node scripts/test/run-e2e.mjs +== roundtrip OK: rolledBack=11 reapplied=11 + Tests 43 failed | 11 passed (54) +| K13/SYNC-A | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +| K13/SYNC-B | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +exit=1 + +# Было (основная ветка f5ac179 + тот же стенд): Tests 45 failed | 9 passed (54) ; run-e2e OK +# Отличия по ID (compare по e2e-results.json, ULID/порты замаскированы): +| ID | было | стало | фактический вывод | +| K13/SYNC-A | RED | GREEN | было: AssertionError: HTTP 200 {"results":[{"idempotency_key":"e2e-","command_type":"upsert_work_session","status":"applied","result":{"id":"","revision":1,"status" / стало: passed | +| K13/SYNC-B | RED | GREEN | было: AssertionError: HTTP 200 {"results":[{"idempotency_key":"e2e-","command_type":"upsert_timesheet_entry","status":"applied","result":{"id":"","revision":1,"stat / стало: passed | + +# Перепроверка: ветку обновили force-push во время приёмки; новый HEAD 1df2019cb89cad69e2c86608dd3b43e18275e97c (2026-09-26 ~15:40 UTC+7) +$ cd && E2E_MIGRATIONS=roundtrip E2E_DATABASE_NAME=krona_staff_acc node scripts/test/run-e2e.mjs +== roundtrip OK: rolledBack=11 reapplied=11 + Tests 43 failed | 11 passed (54) +| K13/SYNC-A | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +| K13/SYNC-B | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +exit=1 +| ID | было | стало | фактический вывод | +| K13/SYNC-A | RED | GREEN | было: AssertionError: HTTP 200 {"results":[{"idempotency_key":"e2e-","command_type":"upsert_work_session","status":"applied","result":{"id":"","revision":1,"status" / стало: passed | +| K13/SYNC-B | RED | GREEN | было: AssertionError: HTTP 200 {"results":[{"idempotency_key":"e2e-","command_type":"upsert_timesheet_entry","status":"applied","result":{"id":"","revision":1,"stat / стало: passed | diff --git a/tests/e2e/report-evidence/acceptance-codex-fix-format-baseline.txt b/tests/e2e/report-evidence/acceptance-codex-fix-format-baseline.txt new file mode 100644 index 0000000..ee05cee --- /dev/null +++ b/tests/e2e/report-evidence/acceptance-codex-fix-format-baseline.txt @@ -0,0 +1,31 @@ +# Приёмка ветки codex/fix-format-baseline @ fd1ec3d8197c21afe15a8dc8101816c52226ebef (2026-09-26, UTC+7) +# Код продукта — ветка исполнителя (git worktree --detach origin/codex/fix-format-baseline), поверх — tests/e2e и scripts/test из grok/e2e-harness @ 3475133 (без коммитов в ветку исполнителя). +# PostgreSQL 17.11, мигратор NOSUPERUSER, сервер под krona_staff_app. + +$ cd && E2E_MIGRATIONS=roundtrip E2E_DATABASE_NAME=krona_staff_acc node scripts/test/run-e2e.mjs +== roundtrip OK: rolledBack=11 reapplied=11 + Tests 43 failed | 11 passed (54) +| K13/SYNC-A | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +| K13/SYNC-B | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +exit=1 + +# Было (основная ветка f5ac179 + тот же стенд): Tests 45 failed | 9 passed (54) ; run-e2e OK +# Отличия по ID (compare по e2e-results.json, ULID/порты замаскированы): +| ID | было | стало | фактический вывод | +| K13/SYNC-A | RED | GREEN | было: AssertionError: HTTP 200 {"results":[{"idempotency_key":"e2e-","command_type":"upsert_work_session","status":"applied","result":{"id":"","revision":1,"status" / стало: passed | +| K13/SYNC-B | RED | GREEN | было: AssertionError: HTTP 200 {"results":[{"idempotency_key":"e2e-","command_type":"upsert_timesheet_entry","status":"applied","result":{"id":"","revision":1,"stat / стало: passed | + +# F14: форматирование ветки (оверлей tests/e2e/evidence удалён перед проверкой — в ветке исполнителя нет записи .gitignore для него) +$ node node_modules/prettier/bin/prettier.cjs --check . +Checking formatting... +All matched files use Prettier code style! +# Было (основная ветка f5ac179): [warn] Code style issues found in 23 files. + +# Находка: коммит fd1ec3d «style: establish repository formatting baseline» содержит не только форматирование. +# Сравнение после нормализации prettier (--config .prettierrc.json), число строк diff: +# src/domain/sync/service.ts 331e490 → fd1ec3d: 255; antigravity/fix-reject-device-shifts → fd1ec3d: 0 +# tests/unit/sync.test.ts 331e490 → fd1ec3d: 139; antigravity/fix-reject-device-shifts → fd1ec3d: 0 +# src/sync.ts, src/domain/sync/cursor.ts: antigravity → fd1ec3d: 0 +# То есть в «style»-коммит вошло функциональное исправление Antigravity (отклонение +# upsert_work_session/upsert_timesheet_entry, COMMAND_NOT_SUPPORTED) — поэтому K13/SYNC-A/B +# зелёные и на этой ветке. Для ревью это смешение тем: изменение поведения скрыто в форматировании. diff --git a/tests/e2e/report-evidence/acceptance-codex-fix-live-database.txt b/tests/e2e/report-evidence/acceptance-codex-fix-live-database.txt new file mode 100644 index 0000000..952cbe8 --- /dev/null +++ b/tests/e2e/report-evidence/acceptance-codex-fix-live-database.txt @@ -0,0 +1,49 @@ +# Приёмка ветки codex/fix-live-database @ ea68e123506cc29276514f67b1dda7cef77057df (2026-09-26, UTC+7) +# Код продукта — ветка исполнителя (git worktree --detach origin/codex/fix-live-database), поверх — tests/e2e и scripts/test из grok/e2e-harness @ 3475133 (без коммитов в ветку исполнителя). +# PostgreSQL 17.11, мигратор NOSUPERUSER, сервер под krona_staff_app. + +$ cd && E2E_MIGRATIONS=roundtrip E2E_DATABASE_NAME=krona_staff_acc node scripts/test/run-e2e.mjs +== roundtrip OK: rolledBack=11 reapplied=11 + Tests 41 failed | 13 passed (54) +| K13/SYNC-A | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +| K13/SYNC-B | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +| K3/N2 | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +| K4/N1 | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +exit=1 + +# Было (основная ветка f5ac179 + тот же стенд): Tests 45 failed | 9 passed (54) ; run-e2e OK +# Отличия по ID (compare по e2e-results.json, ULID/порты замаскированы): +| ID | было | стало | фактический вывод | +| A4 | RED | RED | было: AssertionError: тела 500 с деталями PostgreSQL: expected [ …(2) ] to deeply equal [] / стало: AssertionError: тела 500 с деталями PostgreSQL: expected [ { …(2) } ] to deeply equal [] | +| K13/SYNC-A | RED | GREEN | было: AssertionError: HTTP 200 {"results":[{"idempotency_key":"e2e-","command_type":"upsert_work_session","status":"applied","result":{"id":"","revision":1,"status" / стало: passed | +| K13/SYNC-B | RED | GREEN | было: AssertionError: HTTP 200 {"results":[{"idempotency_key":"e2e-","command_type":"upsert_timesheet_entry","status":"applied","result":{"id":"","revision":1,"stat / стало: passed | +| K3/N2 | RED | GREEN | было: AssertionError: подтверждение смены: HTTP 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value in column \"occurred_at\" of relation / стало: passed | +| K4/N1 | RED | GREEN | было: AssertionError: HTTP 500 {"statusCode":500,"code":"42P08","error":"Internal Server Error","message":"inconsistent types deduced for parameter $2"}: expected 500 to be 201 / стало: passed | +| K5/N4 | RED | RED | было: AssertionError: подтверждение смены (предусловие, см. N2): HTTP 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value in column \"occ / стало: AssertionError: смена должна быть начислена ровно один раз: expected 196800n to be 98400n // Object.is equality | +| K6/D5 | RED | RED | было: AssertionError: подтверждение смены (предусловие; падает, пока открыт K3): HTTP 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value / стало: AssertionError: после отклонения оплаченной смены должно быть сторно: итог начислений 0: expected 98400n to be 0n // Object.is equality | +| K8/D1 | RED | RED | было: AssertionError: подтверждение смены (предусловие; падает, пока открыт K3): HTTP 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value / стало: AssertionError: C4 verify: HTTP 404 {"message":"Route GET:/api/v1/staff/payroll/runs//verify not found","error":"Not Found","statusCode":404}: expected 404 to be 20 | + +# F14: форматирование ветки (оверлей tests/e2e/evidence удалён перед проверкой — в ветке исполнителя нет записи .gitignore для него) +$ node node_modules/prettier/bin/prettier.cjs --check . +Checking formatting... +All matched files use Prettier code style! +# Было (основная ветка f5ac179): [warn] Code style issues found in 23 files. + +# Перепроверка: ветку обновили force-push во время приёмки; новый HEAD b289dd6d63b8f5bcd7c03a88c07200e2e4a704bc (2026-09-26 ~15:40 UTC+7) +$ cd && E2E_MIGRATIONS=roundtrip E2E_DATABASE_NAME=krona_staff_acc node scripts/test/run-e2e.mjs +== roundtrip OK: rolledBack=11 reapplied=11 + Tests 41 failed | 13 passed (54) +| K13/SYNC-A | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +| K13/SYNC-B | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +| K3/N2 | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +| K4/N1 | GREEN | 1/1 | зелёный — ИСПРАВЛЕН, уберите из known-defects.json | +exit=1 +| ID | было | стало | фактический вывод | +| A4 | RED | RED | было: AssertionError: тела 500 с деталями PostgreSQL: expected [ …(2) ] to deeply equal [] / стало: AssertionError: тела 500 с деталями PostgreSQL: expected [ { …(2) } ] to deeply equal [] | +| K13/SYNC-A | RED | GREEN | было: AssertionError: HTTP 200 {"results":[{"idempotency_key":"e2e-","command_type":"upsert_work_session","status":"applied","result":{"id":"","revision":1,"status" / стало: passed | +| K13/SYNC-B | RED | GREEN | было: AssertionError: HTTP 200 {"results":[{"idempotency_key":"e2e-","command_type":"upsert_timesheet_entry","status":"applied","result":{"id":"","revision":1,"stat / стало: passed | +| K3/N2 | RED | GREEN | было: AssertionError: подтверждение смены: HTTP 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value in column \"occurred_at\" of relation / стало: passed | +| K4/N1 | RED | GREEN | было: AssertionError: HTTP 500 {"statusCode":500,"code":"42P08","error":"Internal Server Error","message":"inconsistent types deduced for parameter $2"}: expected 500 to be 201 / стало: passed | +| K5/N4 | RED | RED | было: AssertionError: подтверждение смены (предусловие, см. N2): HTTP 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value in column \"occ / стало: AssertionError: смена должна быть начислена ровно один раз: expected 196800n to be 98400n // Object.is equality | +| K6/D5 | RED | RED | было: AssertionError: подтверждение смены (предусловие; падает, пока открыт K3): HTTP 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value / стало: AssertionError: после отклонения оплаченной смены должно быть сторно: итог начислений 0: expected 98400n to be 0n // Object.is equality | +| K8/D1 | RED | RED | было: AssertionError: подтверждение смены (предусловие; падает, пока открыт K3): HTTP 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value / стало: AssertionError: C4 verify: HTTP 404 {"message":"Route GET:/api/v1/staff/payroll/runs//verify not found","error":"Not Found","statusCode":404}: expected 404 to be 20 | diff --git a/tests/e2e/report-evidence/n13-race.txt b/tests/e2e/report-evidence/n13-race.txt new file mode 100644 index 0000000..a6b1ad2 --- /dev/null +++ b/tests/e2e/report-evidence/n13-race.txt @@ -0,0 +1,58 @@ +$ node scripts/test/n13-race.mjs --mode as-is --attempts 5 +[n13] mode=as-is sessions=false N=10 attempt=1: statuses={"500":10} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":10} -> BAD +[n13] mode=as-is sessions=false N=10 attempt=2: statuses={"500":10} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":10} -> BAD +[n13] mode=as-is sessions=false N=10 attempt=3: statuses={"500":10} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":10} -> BAD +[n13] mode=as-is sessions=false N=10 attempt=4: statuses={"500":10} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":10} -> BAD +[n13] mode=as-is sessions=false N=10 attempt=5: statuses={"500":10} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":10} -> BAD +[n13] mode=as-is sessions=false N=25 attempt=1: statuses={"500":25} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":25} -> BAD +[n13] mode=as-is sessions=false N=25 attempt=2: statuses={"500":25} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":25} -> BAD +[n13] mode=as-is sessions=false N=25 attempt=3: statuses={"500":25} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":25} -> BAD +[n13] mode=as-is sessions=false N=25 attempt=4: statuses={"500":25} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":25} -> BAD +[n13] mode=as-is sessions=false N=25 attempt=5: statuses={"500":25} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":25} -> BAD +[n13] mode=as-is sessions=false N=50 attempt=1: statuses={"500":50} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":50} -> BAD +[n13] mode=as-is sessions=false N=50 attempt=2: statuses={"500":50} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":50} -> BAD +[n13] mode=as-is sessions=false N=50 attempt=3: statuses={"500":50} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":50} -> BAD +[n13] mode=as-is sessions=false N=50 attempt=4: statuses={"500":50} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":50} -> BAD +[n13] mode=as-is sessions=false N=50 attempt=5: statuses={"500":50} distinct_run_ids=0 db_runs=0 db_lines=0 phantom_run_ids=0 errors={"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint":50} -> BAD +[n13] ИТОГ mode=as-is sessions=false: попыток 15, некорректных 15 +exit=1 + +$ node scripts/test/n13-race.mjs --mode n2-bypass --attempts 5 +[n13] ДИАГНОСТИКА: staff_outbox.occurred_at DEFAULT now() (обход N2 только в этой БД) +[n13] mode=n2-bypass sessions=false N=10 attempt=1: statuses={"200":10} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=false N=10 attempt=2: statuses={"200":10} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=false N=10 attempt=3: statuses={"200":10} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=false N=10 attempt=4: statuses={"200":10} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=false N=10 attempt=5: statuses={"200":10} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=false N=25 attempt=1: statuses={"200":25} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=false N=25 attempt=2: statuses={"200":25} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=false N=25 attempt=3: statuses={"200":25} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=false N=25 attempt=4: statuses={"200":25} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=false N=25 attempt=5: statuses={"200":25} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=false N=50 attempt=1: statuses={"200":50} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=false N=50 attempt=2: statuses={"200":50} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=false N=50 attempt=3: statuses={"200":50} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=false N=50 attempt=4: statuses={"200":50} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=false N=50 attempt=5: statuses={"200":50} distinct_run_ids=1 db_runs=1 db_lines=0 phantom_run_ids=0 errors={} -> OK +[n13] ИТОГ mode=n2-bypass sessions=false: попыток 15, некорректных 0 +exit=0 + +$ node scripts/test/n13-race.mjs --mode n2-bypass --attempts 5 --with-sessions --db krona_staff_n13_sessions +[n13] ДИАГНОСТИКА: staff_outbox.occurred_at DEFAULT now() (обход N2 только в этой БД) +[n13] mode=n2-bypass sessions=true N=10 attempt=1: statuses={"200":10} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=true N=10 attempt=2: statuses={"200":10} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=true N=10 attempt=3: statuses={"200":10} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=true N=10 attempt=4: statuses={"200":10} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=true N=10 attempt=5: statuses={"200":10} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=true N=25 attempt=1: statuses={"200":25} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=true N=25 attempt=2: statuses={"200":25} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=true N=25 attempt=3: statuses={"200":25} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=true N=25 attempt=4: statuses={"200":25} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=true N=25 attempt=5: statuses={"200":25} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=true N=50 attempt=1: statuses={"200":50} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=true N=50 attempt=2: statuses={"200":50} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=true N=50 attempt=3: statuses={"200":50} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=true N=50 attempt=4: statuses={"200":50} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK +[n13] mode=n2-bypass sessions=true N=50 attempt=5: statuses={"200":50} distinct_run_ids=1 db_runs=1 db_lines=1 phantom_run_ids=0 errors={} -> OK +[n13] ИТОГ mode=n2-bypass sessions=true: попыток 15, некорректных 0 +exit=0 diff --git a/tests/e2e/report-evidence/n5-after.txt b/tests/e2e/report-evidence/n5-after.txt new file mode 100644 index 0000000..0255aa4 --- /dev/null +++ b/tests/e2e/report-evidence/n5-after.txt @@ -0,0 +1,22 @@ +== run 1: DATABASE_TEST_URL= DATABASE_TEST_MIGRATION_URL= npx vitest run tests/integration/tenant-isolation.test.ts + + RUN v5.0.1 /workspace/krona-staff + + + Test Files 1 passed (1) + Tests 1 passed (1) + Start at 14:20:56 + Duration 275ms (tests 63%, transform 23%, import 12%, worker 1%) + +exit=0 +== run 2: DATABASE_TEST_URL= DATABASE_TEST_MIGRATION_URL= npx vitest run tests/integration/tenant-isolation.test.ts + + RUN v5.0.1 /workspace/krona-staff + + + Test Files 1 passed (1) + Tests 1 passed (1) + Start at 14:20:57 + Duration 260ms (tests 62%, transform 23%, import 13%, worker 2%) + +exit=0 diff --git a/tests/e2e/report-evidence/n5-before.txt b/tests/e2e/report-evidence/n5-before.txt new file mode 100644 index 0000000..d2fa1d7 --- /dev/null +++ b/tests/e2e/report-evidence/n5-before.txt @@ -0,0 +1,30 @@ +== DATABASE_TEST_URL= npx vitest run tests/integration/tenant-isolation.test.ts (база 778ba2d, до исправления) + + RUN v5.0.1 /workspace/krona-staff + + ❯ tests/integration/tenant-isolation.test.ts (1 test) 114ms + +⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/integration/tenant-isolation.test.ts > PostgreSQL tenant isolation +error: permission denied for table staff_employees + ❯ node_modules/pg/lib/client.js:694:17 + ❯ tests/integration/tenant-isolation.test.ts:40:7 + 38| await client.query('DELETE FROM staff_outbox WHERE id = $1', [ev… + 39| await client.query('DELETE FROM staff_professions WHERE id = $1'… + 40| await client.query('DELETE FROM staff_employees WHERE id = $1', … + | ^ + 41| }); + 42| await database.close(); + ❯ Database.tenantTransaction src/infrastructure/database.ts:30:22 + ❯ tests/integration/tenant-isolation.test.ts:31:5 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + + Test Files 1 failed (1) + Tests 1 passed (1) + Start at 14:20:47 + Duration 265ms (tests 62%, transform 23%, import 14%, worker 1%) + +exit=1 diff --git a/tests/e2e/report-evidence/round3-accept-all-open.md b/tests/e2e/report-evidence/round3-accept-all-open.md new file mode 100644 index 0000000..2e8d470 --- /dev/null +++ b/tests/e2e/report-evidence/round3-accept-all-open.md @@ -0,0 +1,140 @@ +# Команда: node scripts/test/accept-branch.mjs $(все origin/codex/* и origin/antigravity/* кроме основы) — после git fetch в 16:16 NOVT + +## accept-branch — 2026-09-26 09:20 UTC + +База: `origin/codex/bootstrap-platform-foundation` = b0243d5; стенд (оверлей tests/e2e, scripts/test): 6f408bc; БД: роли созданы один раз через E2E_PG_ADMIN_URL (provision-pg), дальше --roles keep. Логи: `tests/e2e/evidence/accept/2026-09-26T09-16-26/`. + +### Сводка + +| Ветка | SHA | npm run check | integration (красные файлы) | e2e red/green | e2e: стало зелёным | e2e: стало красным | +| ------------------------------------------------- | ------- | -------------- | --------------------------- | ------------------------------------------- | ------------------ | ------------------------------- | +| origin/codex/bootstrap-platform-foundation (база) | b0243d5 | OK (195 passed | 1 skipped (196)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 46/5 | n/a | n/a | +| origin/antigravity/employee-notifications | 3e20544 | OK (164 passed | 1 skipped (165)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 49/2 | — | HARNESS (AssertionError: expected 10 to be greater than or equal to 11); PILOT-REHOME-SEED (error: relation "staff_sync_change_log" does not exist); SYNC-L (AssertionError: HTTP 404 {"message":"Route POST:/api/v1/staff/sync/devices not found","error":"Not Found","statusCode":404}: expected 404 to be 201 // Object.is) | +| origin/antigravity/fix-reject-device-shifts | b283736 | OK (196 passed | 3 skipped (199)) | 4/5 зелёных; FAIL: tenant-isolation.test.ts | 44/7 | K13/SYNC-A, K13/SYNC-B | — | +| origin/antigravity/fix-sync-c0-bootstrap | 24cc18d | OK (197 passed | 5 skipped (202)) | 5/6 зелёных; FAIL: tenant-isolation.test.ts | 43/8 | K13/SYNC-A, K13/SYNC-B, SYNC-C0 | — | +| origin/antigravity/offline-sync-backend | 5ee7517 | OK (192 passed | 1 skipped (193)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 46/5 | — | — | +| origin/codex/finance-calendar-dashboard | c08a7e6 | OK (113 passed | 1 skipped (114)) | 4/4 зелёных | 48/3 | A4, K4/N1 | CTRL (AssertionError: expected 200 to be 401 // Object.is equality); HARNESS (AssertionError: expected 8 to be greater than or equal to 11); PILOT-REHOME-SEED (error: relation "staff_professions" does not exist); SYNC-L (AssertionError: HTTP 404 {"message":"Route POST:/api/v1/staff/sync/devices not found","error":"Not Found","statusCode":404}: expected 404 to be 201 // Object.is) | +| origin/codex/fix-format-baseline | a05777c | OK (195 passed | 1 skipped (196)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 46/5 | — | — | +| origin/codex/fix-live-database | 1573860 | OK (197 passed | 2 skipped (199)) | 4/5 зелёных; FAIL: tenant-isolation.test.ts | 44/7 | K3/N2, K4/N1 | — | +| origin/codex/frontend-library | 331e490 | OK (195 passed | 1 skipped (196)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 46/5 | — | — | +| origin/codex/security-finance-production | f74aa6d | OK (142 passed | 1 skipped (143)) | 4/4 зелёных | 49/2 | — | HARNESS (AssertionError: expected 9 to be greater than or equal to 11); PILOT-REHOME-SEED (error: relation "staff_push_subscriptions" does not exist); SYNC-L (AssertionError: HTTP 404 {"message":"Route POST:/api/v1/staff/sync/devices not found","error":"Not Found","statusCode":404}: expected 404 to be 201 // Object.is) | +| origin/codex/server-web-production | 2f6b367 | OK (113 passed | 1 skipped (114)) | 4/4 зелёных | 49/2 | K4/N1 | CTRL (AssertionError: HTTP 404 {"message":"Route POST:/api/v1/staff/imports/owner-workbook not found","error":"Not Found","statusCode":404}: expected 404 to be less t); HARNESS (AssertionError: expected 7 to be greater than or equal to 11); PILOT-REHOME-SEED (error: relation "staff_professions" does not exist); SYNC-L (AssertionError: HTTP 404 {"message":"Route POST:/api/v1/staff/sync/devices not found","error":"Not Found","statusCode":404}: expected 404 to be 201 // Object.is) | + +### Интеграционные тесты (каждый файл отдельно, свежая БД) + +| Ветка | Файл | Итог | Test Files | Tests | Первая ошибка | +| ------------------------------------------- | --------------------------------- | ---- | ------------ | -------------- | -------------------------------------------------- | +| origin/codex/bootstrap-platform-foundation | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/codex/bootstrap-platform-foundation | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/codex/bootstrap-platform-foundation | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| origin/codex/bootstrap-platform-foundation | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees | +| origin/antigravity/employee-notifications | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/antigravity/employee-notifications | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/antigravity/employee-notifications | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| origin/antigravity/employee-notifications | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees | +| origin/antigravity/fix-reject-device-shifts | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/antigravity/fix-reject-device-shifts | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/antigravity/fix-reject-device-shifts | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| origin/antigravity/fix-reject-device-shifts | sync-rejection.test.ts | OK | 1 passed (1) | 2 passed (2) | | +| origin/antigravity/fix-reject-device-shifts | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees | +| origin/antigravity/fix-sync-c0-bootstrap | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/antigravity/fix-sync-c0-bootstrap | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/antigravity/fix-sync-c0-bootstrap | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| origin/antigravity/fix-sync-c0-bootstrap | sync-bootstrap.test.ts | OK | 1 passed (1) | 2 passed (2) | | +| origin/antigravity/fix-sync-c0-bootstrap | sync-rejection.test.ts | OK | 1 passed (1) | 2 passed (2) | | +| origin/antigravity/fix-sync-c0-bootstrap | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees | +| origin/antigravity/offline-sync-backend | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/antigravity/offline-sync-backend | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/antigravity/offline-sync-backend | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| origin/antigravity/offline-sync-backend | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees | +| origin/codex/finance-calendar-dashboard | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/codex/finance-calendar-dashboard | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/codex/finance-calendar-dashboard | portal.test.ts | OK | 1 passed (1) | 11 passed (11) | | +| origin/codex/finance-calendar-dashboard | tenant-isolation.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/codex/fix-format-baseline | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/codex/fix-format-baseline | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/codex/fix-format-baseline | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| origin/codex/fix-format-baseline | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees | +| origin/codex/fix-live-database | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/codex/fix-live-database | live-postgres-remediation.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/codex/fix-live-database | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/codex/fix-live-database | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| origin/codex/fix-live-database | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees | +| origin/codex/frontend-library | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/codex/frontend-library | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/codex/frontend-library | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| origin/codex/frontend-library | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees | +| origin/codex/security-finance-production | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/codex/security-finance-production | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/codex/security-finance-production | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| origin/codex/security-finance-production | tenant-isolation.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/codex/server-web-production | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/codex/server-web-production | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/codex/server-web-production | portal.test.ts | OK | 1 passed (1) | 11 passed (11) | | +| origin/codex/server-web-production | tenant-isolation.test.ts | OK | 1 passed (1) | 1 passed (1) | | + +npm ci (lock отличается): origin/codex/finance-calendar-dashboard, origin/codex/security-finance-production, origin/codex/server-web-production + +### Попарные слияния (`git merge-tree --write-tree`) + +| Ветка A | Ветка B | Результат | Конфликтные файлы | +| ------------------------------------------- | ------------------------------------------- | --------- | ----------------- | +| origin/codex/bootstrap-platform-foundation | origin/antigravity/employee-notifications | чисто | | +| origin/codex/bootstrap-platform-foundation | origin/antigravity/fix-reject-device-shifts | чисто | | +| origin/codex/bootstrap-platform-foundation | origin/antigravity/fix-sync-c0-bootstrap | чисто | | +| origin/codex/bootstrap-platform-foundation | origin/antigravity/offline-sync-backend | чисто | | +| origin/codex/bootstrap-platform-foundation | origin/codex/finance-calendar-dashboard | чисто | | +| origin/codex/bootstrap-platform-foundation | origin/codex/fix-format-baseline | чисто | | +| origin/codex/bootstrap-platform-foundation | origin/codex/fix-live-database | чисто | | +| origin/codex/bootstrap-platform-foundation | origin/codex/frontend-library | чисто | | +| origin/codex/bootstrap-platform-foundation | origin/codex/security-finance-production | чисто | | +| origin/codex/bootstrap-platform-foundation | origin/codex/server-web-production | чисто | | +| origin/antigravity/employee-notifications | origin/antigravity/fix-reject-device-shifts | чисто | | +| origin/antigravity/employee-notifications | origin/antigravity/fix-sync-c0-bootstrap | чисто | | +| origin/antigravity/employee-notifications | origin/antigravity/offline-sync-backend | чисто | | +| origin/antigravity/employee-notifications | origin/codex/finance-calendar-dashboard | чисто | | +| origin/antigravity/employee-notifications | origin/codex/fix-format-baseline | чисто | | +| origin/antigravity/employee-notifications | origin/codex/fix-live-database | чисто | | +| origin/antigravity/employee-notifications | origin/codex/frontend-library | чисто | | +| origin/antigravity/employee-notifications | origin/codex/security-finance-production | чисто | | +| origin/antigravity/employee-notifications | origin/codex/server-web-production | чисто | | +| origin/antigravity/fix-reject-device-shifts | origin/antigravity/fix-sync-c0-bootstrap | чисто | | +| origin/antigravity/fix-reject-device-shifts | origin/antigravity/offline-sync-backend | чисто | | +| origin/antigravity/fix-reject-device-shifts | origin/codex/finance-calendar-dashboard | чисто | | +| origin/antigravity/fix-reject-device-shifts | origin/codex/fix-format-baseline | чисто | | +| origin/antigravity/fix-reject-device-shifts | origin/codex/fix-live-database | чисто | | +| origin/antigravity/fix-reject-device-shifts | origin/codex/frontend-library | чисто | | +| origin/antigravity/fix-reject-device-shifts | origin/codex/security-finance-production | чисто | | +| origin/antigravity/fix-reject-device-shifts | origin/codex/server-web-production | чисто | | +| origin/antigravity/fix-sync-c0-bootstrap | origin/antigravity/offline-sync-backend | чисто | | +| origin/antigravity/fix-sync-c0-bootstrap | origin/codex/finance-calendar-dashboard | чисто | | +| origin/antigravity/fix-sync-c0-bootstrap | origin/codex/fix-format-baseline | чисто | | +| origin/antigravity/fix-sync-c0-bootstrap | origin/codex/fix-live-database | чисто | | +| origin/antigravity/fix-sync-c0-bootstrap | origin/codex/frontend-library | чисто | | +| origin/antigravity/fix-sync-c0-bootstrap | origin/codex/security-finance-production | чисто | | +| origin/antigravity/fix-sync-c0-bootstrap | origin/codex/server-web-production | чисто | | +| origin/antigravity/offline-sync-backend | origin/codex/finance-calendar-dashboard | чисто | | +| origin/antigravity/offline-sync-backend | origin/codex/fix-format-baseline | чисто | | +| origin/antigravity/offline-sync-backend | origin/codex/fix-live-database | чисто | | +| origin/antigravity/offline-sync-backend | origin/codex/frontend-library | чисто | | +| origin/antigravity/offline-sync-backend | origin/codex/security-finance-production | чисто | | +| origin/antigravity/offline-sync-backend | origin/codex/server-web-production | чисто | | +| origin/codex/finance-calendar-dashboard | origin/codex/fix-format-baseline | чисто | | +| origin/codex/finance-calendar-dashboard | origin/codex/fix-live-database | чисто | | +| origin/codex/finance-calendar-dashboard | origin/codex/frontend-library | чисто | | +| origin/codex/finance-calendar-dashboard | origin/codex/security-finance-production | чисто | | +| origin/codex/finance-calendar-dashboard | origin/codex/server-web-production | чисто | | +| origin/codex/fix-format-baseline | origin/codex/fix-live-database | чисто | | +| origin/codex/fix-format-baseline | origin/codex/frontend-library | чисто | | +| origin/codex/fix-format-baseline | origin/codex/security-finance-production | чисто | | +| origin/codex/fix-format-baseline | origin/codex/server-web-production | чисто | | +| origin/codex/fix-live-database | origin/codex/frontend-library | чисто | | +| origin/codex/fix-live-database | origin/codex/security-finance-production | чисто | | +| origin/codex/fix-live-database | origin/codex/server-web-production | чисто | | +| origin/codex/frontend-library | origin/codex/security-finance-production | чисто | | +| origin/codex/frontend-library | origin/codex/server-web-production | чисто | | +| origin/codex/security-finance-production | origin/codex/server-web-production | чисто | | + +### Форматирование без поведения + +**origin/codex/fix-format-baseline** (a05777c), merge-base f5ac179, изменено файлов: 19 — ТОЛЬКО ФОРМАТИРОВАНИЕ diff --git a/tests/e2e/report-evidence/round3-accept-owner-branches-first.md b/tests/e2e/report-evidence/round3-accept-owner-branches-first.md new file mode 100644 index 0000000..1a50c71 --- /dev/null +++ b/tests/e2e/report-evidence/round3-accept-owner-branches-first.md @@ -0,0 +1,57 @@ +# Команда: node scripts/test/accept-branch.mjs antigravity/fix-reject-device-shifts antigravity/fix-sync-c0-bootstrap codex/fix-format-baseline (16:03 NOVT; ветка Antigravity к этому времени уже 6c5bf36; версия скрипта до вердикта SKIP) + +## accept-branch — 2026-09-26 09:04 UTC + +База: `origin/codex/bootstrap-platform-foundation` = b0243d5; стенд (оверлей tests/e2e, scripts/test): aa7f5f8 (+ незакоммиченные изменения стенда); БД: роли созданы один раз через E2E_PG_ADMIN_URL (provision-pg), дальше --roles keep. Логи: `tests/e2e/evidence/accept/2026-09-26T09-03-17/`. + +### Сводка + +| Ветка | SHA | npm run check | integration (красные файлы) | e2e red/green | e2e: стало зелёным | e2e: стало красным | +| ------------------------------------------------- | ------- | -------------- | --------------------------- | ------------------------------------------------------------------- | ------------------ | ---------------------- | +| origin/codex/bootstrap-platform-foundation (база) | b0243d5 | OK (195 passed | 1 skipped (196)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 45/4 | n/a | n/a | +| origin/antigravity/fix-reject-device-shifts | 6c5bf36 | OK (196 passed | 3 skipped (199)) | 4/5 зелёных; FAIL: tenant-isolation.test.ts | 43/6 | K13/SYNC-A, K13/SYNC-B | — | +| origin/antigravity/fix-sync-c0-bootstrap | 8baad55 | OK (196 passed | 3 skipped (199)) | 3/5 зелёных; FAIL: sync-bootstrap.test.ts, tenant-isolation.test.ts | 44/5 | SYNC-C0 | — | +| origin/codex/fix-format-baseline | 513cdff | OK (196 passed | 1 skipped (197)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 43/6 | K13/SYNC-A, K13/SYNC-B | — | + +### Интеграционные тесты (каждый файл отдельно, свежая БД) + +| Ветка | Файл | Итог | Test Files | Tests | Первая ошибка | +| ------------------------------------------- | ------------------------ | ---- | ------------ | -------------- | -------------------------------------------------- | +| origin/codex/bootstrap-platform-foundation | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/codex/bootstrap-platform-foundation | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/codex/bootstrap-platform-foundation | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| origin/codex/bootstrap-platform-foundation | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees | +| origin/antigravity/fix-reject-device-shifts | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/antigravity/fix-reject-device-shifts | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/antigravity/fix-reject-device-shifts | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| origin/antigravity/fix-reject-device-shifts | sync-rejection.test.ts | OK | 1 passed (1) | 2 passed (2) | | +| origin/antigravity/fix-reject-device-shifts | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees | +| origin/antigravity/fix-sync-c0-bootstrap | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/antigravity/fix-sync-c0-bootstrap | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/antigravity/fix-sync-c0-bootstrap | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| origin/antigravity/fix-sync-c0-bootstrap | sync-bootstrap.test.ts | FAIL | 1 failed (1) | 2 passed (2) | Error: Called end on pool more than once | +| origin/antigravity/fix-sync-c0-bootstrap | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees | +| origin/codex/fix-format-baseline | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/codex/fix-format-baseline | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/codex/fix-format-baseline | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| origin/codex/fix-format-baseline | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees | + +### Попарные слияния (`git merge-tree --write-tree`) + +| Ветка A | Ветка B | Результат | Конфликтные файлы | +| ------------------------------------------- | ------------------------------------------- | --------- | -------------------------- | +| origin/codex/bootstrap-platform-foundation | origin/antigravity/fix-reject-device-shifts | чисто | | +| origin/codex/bootstrap-platform-foundation | origin/antigravity/fix-sync-c0-bootstrap | чисто | | +| origin/codex/bootstrap-platform-foundation | origin/codex/fix-format-baseline | чисто | | +| origin/antigravity/fix-reject-device-shifts | origin/antigravity/fix-sync-c0-bootstrap | КОНФЛИКТ | src/domain/sync/service.ts | +| origin/antigravity/fix-reject-device-shifts | origin/codex/fix-format-baseline | чисто | | +| origin/antigravity/fix-sync-c0-bootstrap | origin/codex/fix-format-baseline | КОНФЛИКТ | src/domain/sync/service.ts | + +### Форматирование без поведения + +**origin/codex/fix-format-baseline** (513cdff), merge-base f5ac179, изменено файлов: 24 — ЕСТЬ ИЗМЕНЕНИЯ СВЕРХ PRETTIER + +| Файл | Почему | Строк сверх prettier | Эти же строки в ветке | +| -------------------------- | ----------------------------- | -------------------- | ---------------------------------------------------------------------------------------- | +| src/domain/sync/service.ts | не совпадает с prettier(база) | 232 | antigravity/fix-reject-device-shifts: 232/232; antigravity/fix-sync-c0-bootstrap: 6/232 | +| tests/unit/sync.test.ts | не совпадает с prettier(база) | 130 | antigravity/fix-reject-device-shifts: 130/130; antigravity/fix-sync-c0-bootstrap: 12/130 | diff --git a/tests/e2e/report-evidence/round3-accept-owner-shas.md b/tests/e2e/report-evidence/round3-accept-owner-shas.md new file mode 100644 index 0000000..eba1c2d --- /dev/null +++ b/tests/e2e/report-evidence/round3-accept-owner-shas.md @@ -0,0 +1,57 @@ +# Команда: node scripts/test/accept-branch.mjs 1df2019 antigravity/fix-sync-c0-bootstrap codex/fix-format-baseline (SHA 1df2019 = прежняя голова antigravity/fix-reject-device-shifts; 8baad55, 513cdff — головы на момент прогона, 16:04 NOVT) + +## accept-branch — 2026-09-26 09:06 UTC + +База: `origin/codex/bootstrap-platform-foundation` = b0243d5; стенд (оверлей tests/e2e, scripts/test): aa7f5f8 (+ незакоммиченные изменения стенда); БД: роли созданы один раз через E2E_PG_ADMIN_URL (provision-pg), дальше --roles keep. Логи: `tests/e2e/evidence/accept/2026-09-26T09-05-12/`. + +### Сводка + +| Ветка | SHA | npm run check | integration (красные файлы) | e2e red/green | e2e: стало зелёным | e2e: стало красным | +| ------------------------------------------------- | ------- | -------------- | --------------------------- | ------------------------------------------------------------------- | ------------------ | ---------------------- | +| origin/codex/bootstrap-platform-foundation (база) | b0243d5 | OK (195 passed | 1 skipped (196)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 45/4 | n/a | n/a | +| 1df2019 | 1df2019 | OK (196 passed | 3 skipped (199)) | 3/5 зелёных; FAIL: sync-rejection.test.ts, tenant-isolation.test.ts | 43/6 | K13/SYNC-A, K13/SYNC-B | — | +| origin/antigravity/fix-sync-c0-bootstrap | 8baad55 | OK (196 passed | 3 skipped (199)) | 3/5 зелёных; FAIL: sync-bootstrap.test.ts, tenant-isolation.test.ts | 44/5 | SYNC-C0 | — | +| origin/codex/fix-format-baseline | 513cdff | OK (196 passed | 1 skipped (197)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 43/6 | K13/SYNC-A, K13/SYNC-B | — | + +### Интеграционные тесты (каждый файл отдельно, свежая БД) + +| Ветка | Файл | Итог | Test Files | Tests | Первая ошибка | +| ------------------------------------------ | ------------------------ | ---- | ------------ | -------------- | ----------------------------------------------------------------------- | +| origin/codex/bootstrap-platform-foundation | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/codex/bootstrap-platform-foundation | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/codex/bootstrap-platform-foundation | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| origin/codex/bootstrap-platform-foundation | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees | +| 1df2019 | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| 1df2019 | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| 1df2019 | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| 1df2019 | sync-rejection.test.ts | FAIL | 1 failed (1) | 2 skipped (2) | error: column "created_by" of relation "staff_employees" does not exist | +| 1df2019 | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees | +| origin/antigravity/fix-sync-c0-bootstrap | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/antigravity/fix-sync-c0-bootstrap | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/antigravity/fix-sync-c0-bootstrap | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| origin/antigravity/fix-sync-c0-bootstrap | sync-bootstrap.test.ts | FAIL | 1 failed (1) | 2 passed (2) | Error: Called end on pool more than once | +| origin/antigravity/fix-sync-c0-bootstrap | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees | +| origin/codex/fix-format-baseline | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/codex/fix-format-baseline | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/codex/fix-format-baseline | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| origin/codex/fix-format-baseline | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees | + +### Попарные слияния (`git merge-tree --write-tree`) + +| Ветка A | Ветка B | Результат | Конфликтные файлы | +| ------------------------------------------ | ---------------------------------------- | --------- | -------------------------- | +| origin/codex/bootstrap-platform-foundation | 1df2019 | чисто | | +| origin/codex/bootstrap-platform-foundation | origin/antigravity/fix-sync-c0-bootstrap | чисто | | +| origin/codex/bootstrap-platform-foundation | origin/codex/fix-format-baseline | чисто | | +| 1df2019 | origin/antigravity/fix-sync-c0-bootstrap | КОНФЛИКТ | src/domain/sync/service.ts | +| 1df2019 | origin/codex/fix-format-baseline | чисто | | +| origin/antigravity/fix-sync-c0-bootstrap | origin/codex/fix-format-baseline | КОНФЛИКТ | src/domain/sync/service.ts | + +### Форматирование без поведения + +**origin/codex/fix-format-baseline** (513cdff), merge-base f5ac179, изменено файлов: 24 — ЕСТЬ ИЗМЕНЕНИЯ СВЕРХ PRETTIER + +| Файл | Почему | Строк сверх prettier | Эти же строки в ветке | +| -------------------------- | ----------------------------- | -------------------- | ----------------------------------------------------------- | +| src/domain/sync/service.ts | не совпадает с prettier(база) | 232 | 1df2019: 232/232; antigravity/fix-sync-c0-bootstrap: 6/232 | +| tests/unit/sync.test.ts | не совпадает с prettier(база) | 130 | 1df2019: 130/130; antigravity/fix-sync-c0-bootstrap: 12/130 | diff --git a/tests/e2e/report-evidence/round3-combined-merge.md b/tests/e2e/report-evidence/round3-combined-merge.md new file mode 100644 index 0000000..e4436e3 --- /dev/null +++ b/tests/e2e/report-evidence/round3-combined-merge.md @@ -0,0 +1,82 @@ +# Локальные ветки (не запушены): tmp/combined-round3 = b0243d5 + merge origin/codex/fix-live-database (1573860) + merge origin/antigravity/fix-sync-c0-bootstrap (24cc18d), без конфликтов; tmp/combined-round3-grok = то же + merge grok/e2e-harness (6f408bc). Команда: node scripts/test/accept-branch.mjs <ветка> + +## accept-branch — 2026-09-26 09:22 UTC + +База: `origin/codex/bootstrap-platform-foundation` = b0243d5; стенд (оверлей tests/e2e, scripts/test): 6f408bc; БД: роли созданы один раз через E2E_PG_ADMIN_URL (provision-pg), дальше --roles keep. Логи: `tests/e2e/evidence/accept/2026-09-26T09-22-01/`. + +### Сводка + +| Ветка | SHA | npm run check | integration (красные файлы) | e2e red/green | e2e: стало зелёным | e2e: стало красным | +| ------------------------------------------------- | ------- | -------------- | --------------------------- | ------------------------------------------- | ------------------ | --------------------------------------------- | +| origin/codex/bootstrap-platform-foundation (база) | b0243d5 | OK (195 passed | 1 skipped (196)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 46/5 | n/a | n/a | +| tmp/combined-round3 | 48128c8 | OK (199 passed | 6 skipped (205)) | 6/7 зелёных; FAIL: tenant-isolation.test.ts | 41/10 | K13/SYNC-A, K13/SYNC-B, K3/N2, K4/N1, SYNC-C0 | — | + +### Интеграционные тесты (каждый файл отдельно, свежая БД) + +| Ветка | Файл | Итог | Test Files | Tests | Первая ошибка | +| ------------------------------------------ | --------------------------------- | ---- | ------------ | -------------- | -------------------------------------------------- | +| origin/codex/bootstrap-platform-foundation | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/codex/bootstrap-platform-foundation | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/codex/bootstrap-platform-foundation | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| origin/codex/bootstrap-platform-foundation | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees | +| tmp/combined-round3 | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| tmp/combined-round3 | live-postgres-remediation.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| tmp/combined-round3 | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| tmp/combined-round3 | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| tmp/combined-round3 | sync-bootstrap.test.ts | OK | 1 passed (1) | 2 passed (2) | | +| tmp/combined-round3 | sync-rejection.test.ts | OK | 1 passed (1) | 2 passed (2) | | +| tmp/combined-round3 | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees | + +### Попарные слияния (`git merge-tree --write-tree`) + +| Ветка A | Ветка B | Результат | Конфликтные файлы | +| ------------------------------------------ | ------------------- | --------- | ----------------- | +| origin/codex/bootstrap-platform-foundation | tmp/combined-round3 | чисто | | + +--- + +## accept-branch — 2026-09-26 09:23 UTC + +База: `origin/codex/bootstrap-platform-foundation` = b0243d5; стенд (оверлей tests/e2e, scripts/test): 6f408bc; БД: роли созданы один раз через E2E_PG_ADMIN_URL (provision-pg), дальше --roles keep. Логи: `tests/e2e/evidence/accept/2026-09-26T09-23-04/`. + +### Сводка + +| Ветка | SHA | npm run check | integration (красные файлы) | e2e red/green | e2e: стало зелёным | e2e: стало красным | +| ------------------------------------------------- | ------- | -------------- | --------------------------- | ------------------------------------------- | ------------------ | --------------------------------------------- | +| origin/codex/bootstrap-platform-foundation (база) | b0243d5 | OK (195 passed | 1 skipped (196)) | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 46/5 | n/a | n/a | +| tmp/combined-round3-grok | e94415a | OK (199 passed | 6 skipped (205)) | 7/7 зелёных | 41/10 | K13/SYNC-A, K13/SYNC-B, K3/N2, K4/N1, SYNC-C0 | — | + +### Интеграционные тесты (каждый файл отдельно, свежая БД) + +| Ветка | Файл | Итог | Test Files | Tests | Первая ошибка | +| ------------------------------------------ | --------------------------------- | ---- | ------------ | -------------- | -------------------------------------------------- | +| origin/codex/bootstrap-platform-foundation | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/codex/bootstrap-platform-foundation | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/codex/bootstrap-platform-foundation | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| origin/codex/bootstrap-platform-foundation | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees | +| tmp/combined-round3-grok | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| tmp/combined-round3-grok | live-postgres-remediation.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| tmp/combined-round3-grok | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| tmp/combined-round3-grok | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| tmp/combined-round3-grok | sync-bootstrap.test.ts | OK | 1 passed (1) | 2 passed (2) | | +| tmp/combined-round3-grok | sync-rejection.test.ts | OK | 1 passed (1) | 2 passed (2) | | +| tmp/combined-round3-grok | tenant-isolation.test.ts | OK | 1 passed (1) | 1 passed (1) | | + +Внимание: ветка меняла файлы стенда, при приёмке они заменены оверлеем: + +- tmp/combined-round3-grok: scripts/test/accept-branch.mjs, scripts/test/ci-local.sh, scripts/test/migrate-roundtrip.mjs, scripts/test/n13-race.mjs, scripts/test/node-commands.d.mts, scripts/test/node-commands.mjs, scripts/test/provision-pg.mjs, scripts/test/run-e2e.mjs, tests/e2e/00-harness.e2e.ts, tests/e2e/10-core-defects.e2e.ts, tests/e2e/20-sync-defects.e2e.ts, tests/e2e/30-owner-decisions.e2e.ts, tests/e2e/40-compose-pilot.e2e.ts, tests/e2e/50-money-and-errors.e2e.ts, tests/e2e/60-pilot-rehome.e2e.ts, tests/e2e/NOTES-FOR-EXECUTORS.md, tests/e2e/REPORT.md, tests/e2e/harness/client.ts, tests/e2e/harness/context.ts, tests/e2e/harness/fixtures.ts, tests/e2e/harness/global-setup.ts, tests/e2e/harness/node-commands.test.ts, tests/e2e/known-defects.json, tests/e2e/report-evidence/acceptance-antigravity-fix-reject-device-shifts.txt, tests/e2e/report-evidence/acceptance-codex-fix-format-baseline.txt, tests/e2e/report-evidence/acceptance-codex-fix-live-database.txt, tests/e2e/report-evidence/n13-race.txt, tests/e2e/report-evidence/n5-after.txt, tests/e2e/report-evidence/n5-before.txt, tests/e2e/report-evidence/round3-pilot-rehome.txt, tests/e2e/vitest.e2e.config.ts + +### Попарные слияния (`git merge-tree --write-tree`) + +| Ветка A | Ветка B | Результат | Конфликтные файлы | +| ------------------------------------------ | ------------------------ | --------- | ----------------- | +| origin/codex/bootstrap-platform-foundation | tmp/combined-round3-grok | чисто | | + +``` +[run-e2e] FAIL: +- K13/SYNC-A: дефект исправлен, обновите known-defects.json +- K13/SYNC-B: дефект исправлен, обновите known-defects.json +- K3/N2: дефект исправлен, обновите known-defects.json +- K4/N1: дефект исправлен, обновите known-defects.json +- SYNC-C0: дефект исправлен, обновите known-defects.json +``` diff --git a/tests/e2e/report-evidence/round3-format-proof.md b/tests/e2e/report-evidence/round3-format-proof.md new file mode 100644 index 0000000..65e1984 --- /dev/null +++ b/tests/e2e/report-evidence/round3-format-proof.md @@ -0,0 +1,19 @@ +# Команда: node scripts/test/accept-branch.mjs --format-check tmp/prettier-only-f5ac179 --format-check codex/fix-format-baseline (tmp/prettier-only-f5ac179 = c552d38: `prettier --write .` поверх f5ac179, локальная ветка, не запушена; 513cdff — прежняя голова codex/fix-format-baseline) + +[accept] tmp/prettier-only-f5ac179: проверка форматирования +[accept] codex/fix-format-baseline: проверка форматирования + +## accept-branch — 2026-09-26 09:03 UTC + +База: `origin/codex/bootstrap-platform-foundation` = b0243d5; стенд (оверлей tests/e2e, scripts/test): aa7f5f8 (+ незакоммиченные изменения стенда); БД: не использовалась. Логи: `tests/e2e/evidence/accept/2026-09-26T09-03-11/`. + +### Форматирование без поведения + +**tmp/prettier-only-f5ac179** (c552d38), merge-base f5ac179, изменено файлов: 24 — ТОЛЬКО ФОРМАТИРОВАНИЕ + +**origin/codex/fix-format-baseline** (513cdff), merge-base f5ac179, изменено файлов: 24 — ЕСТЬ ИЗМЕНЕНИЯ СВЕРХ PRETTIER + +| Файл | Почему | Строк сверх prettier | Эти же строки в ветке | +| -------------------------- | ----------------------------- | -------------------- | --------------------- | +| src/domain/sync/service.ts | не совпадает с prettier(база) | 232 | — | +| tests/unit/sync.test.ts | не совпадает с prettier(база) | 130 | — | 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) } diff --git a/tests/e2e/report-evidence/round3-preset-mode.md b/tests/e2e/report-evidence/round3-preset-mode.md new file mode 100644 index 0000000..95386e8 --- /dev/null +++ b/tests/e2e/report-evidence/round3-preset-mode.md @@ -0,0 +1,26 @@ +# Режим готовых ролей: E2E_MIGRATOR_URL/E2E_APP_URL заданы, E2E_PG_ADMIN_URL не задан. Команда: node scripts/test/accept-branch.mjs --skip-check codex/fix-live-database (8e955b0) + +База: `origin/codex/bootstrap-platform-foundation` = b0243d5; стенд (оверлей tests/e2e, scripts/test): d7b2352 (+ незакоммиченные изменения стенда); БД: роли из E2E_MIGRATOR_URL/E2E_APP_URL (не пересоздавались). Логи: `tests/e2e/evidence/accept/2026-09-26T09-15-44/`. + +### Сводка + +| Ветка | SHA | npm run check | integration (красные файлы) | e2e red/green | e2e: стало зелёным | e2e: стало красным | +| ------------------------------------------------- | ------- | ------------- | ------------------------------------------------------------------------------------------------------------ | ------------- | ------------------------------------ | ------------------ | +| origin/codex/bootstrap-platform-foundation (база) | b0243d5 | не запускался | 3/4 зелёных; FAIL: tenant-isolation.test.ts | 46/5 | n/a | n/a | +| origin/codex/fix-live-database | 8e955b0 | не запускался | 3/5 зелёных; FAIL: tenant-isolation.test.ts; SKIP (файл пропущен целиком): live-postgres-remediation.test.ts | 42/9 | K13/SYNC-A, K13/SYNC-B, K3/N2, K4/N1 | — | + +### Интеграционные тесты (каждый файл отдельно, свежая БД) + +| Ветка | Файл | Итог | Test Files | Tests | Первая ошибка | +| ------------------------------------------ | --------------------------------- | ---- | ------------- | -------------- | -------------------------------------------------- | +| origin/codex/bootstrap-platform-foundation | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/codex/bootstrap-platform-foundation | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/codex/bootstrap-platform-foundation | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| origin/codex/bootstrap-platform-foundation | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees | +| origin/codex/fix-live-database | health.test.ts | OK | 1 passed (1) | 3 passed (3) | | +| origin/codex/fix-live-database | live-postgres-remediation.test.ts | SKIP | 1 skipped (1) | 1 skipped (1) | | +| origin/codex/fix-live-database | openapi-runtime.test.ts | OK | 1 passed (1) | 1 passed (1) | | +| origin/codex/fix-live-database | portal.test.ts | OK | 1 passed (1) | 12 passed (12) | | +| origin/codex/fix-live-database | tenant-isolation.test.ts | FAIL | 1 failed (1) | 1 passed (1) | error: permission denied for table staff_employees | + +### Попарные слияния (`git merge-tree --write-tree`) diff --git a/tests/e2e/vitest.e2e.config.ts b/tests/e2e/vitest.e2e.config.ts new file mode 100644 index 0000000..e2c1648 --- /dev/null +++ b/tests/e2e/vitest.e2e.config.ts @@ -0,0 +1,18 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +// Отдельный конфиг: e2e-файлы названы *.e2e.ts и не попадают в `npm test` +// (корневой vitest ищет только *.test.ts), поэтому `npm run check` не зависит от +// живой PostgreSQL и от известных открытых дефектов. +const root = fileURLToPath(new URL('../..', import.meta.url)); + +export default defineConfig({ + root, + test: { + include: ['tests/e2e/**/*.e2e.ts'], + globalSetup: ['tests/e2e/harness/global-setup.ts'], + fileParallelism: false, + testTimeout: 60_000, + hookTimeout: 180_000, + }, +}); diff --git a/tests/integration/tenant-isolation.test.ts b/tests/integration/tenant-isolation.test.ts index a954a3a..d806d26 100644 --- a/tests/integration/tenant-isolation.test.ts +++ b/tests/integration/tenant-isolation.test.ts @@ -1,3 +1,4 @@ +import pg from 'pg'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { Database } from '../../src/infrastructure/database.js'; import { newUlid, parseUlid } from '../../src/core/ids.js'; @@ -5,6 +6,23 @@ import { newUlid, parseUlid } from '../../src/core/ids.js'; const connectionString = process.env.DATABASE_TEST_URL; const describeDatabase = connectionString ? describe : describe.skip; +// Роль приложения намеренно не имеет DELETE на staff_employees и ряде других таблиц, +// поэтому очистка идёт соединением роли мигратора (владельца таблиц). FORCE RLS действует +// и на владельца, поэтому внутри транзакции выставляется app.tenant_id. +const migrationConnectionString = + process.env.DATABASE_TEST_MIGRATION_URL ?? process.env.DATABASE_MIGRATION_URL; +const cleanupTables = [ + 'staff_sync_device_checkpoints', + 'staff_sync_command_inbox', + 'staff_sync_change_log', + 'staff_sync_devices', + 'staff_notification_deliveries', + 'staff_push_subscriptions', + 'staff_outbox', + 'staff_professions', + 'staff_employees', +] as const; + describeDatabase('PostgreSQL tenant isolation', () => { const database = new Database(connectionString!); const tenantA = parseUlid('01J0000000000000000000000A'); @@ -20,26 +38,51 @@ describeDatabase('PostgreSQL tenant isolation', () => { const changeIdA = newUlid(); const commandIdA = newUlid(); + async function cleanupAsMigrator() { + if (!migrationConnectionString) { + throw new Error( + 'DATABASE_TEST_MIGRATION_URL (или DATABASE_MIGRATION_URL) обязателен вместе с DATABASE_TEST_URL: очистка выполняется ролью мигратора', + ); + } + const migrator = new pg.Client({ + connectionString: migrationConnectionString, + application_name: 'krona-staff-test-cleanup', + }); + await migrator.connect(); + try { + for (const tenant of [tenantA, tenantB]) { + await migrator.query('BEGIN'); + try { + await migrator.query("SELECT set_config('app.tenant_id', $1, true)", [tenant]); + for (const table of cleanupTables) { + await migrator.query(`DELETE FROM ${table} WHERE tenant_id = $1`, [tenant]); + } + await migrator.query('COMMIT'); + } catch (error) { + await migrator.query('ROLLBACK'); + throw error; + } + } + } finally { + await migrator.end(); + } + } + beforeAll(async () => { const role = await database.pool.query( 'SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user', ); expect(role.rows[0]).toEqual({ rolsuper: false, rolbypassrls: false }); + // Остатки прошлого упавшего прогона иначе ломают уникальные ключи (EMP-PUSH-1, DEV-A). + await cleanupAsMigrator(); }); afterAll(async () => { - await database.tenantTransaction(tenantA, actorA, async (client) => { - await client.query('DELETE FROM staff_sync_device_checkpoints WHERE tenant_id = $1', [tenantA]); - await client.query('DELETE FROM staff_sync_command_inbox WHERE tenant_id = $1', [tenantA]); - await client.query('DELETE FROM staff_sync_change_log WHERE tenant_id = $1', [tenantA]); - await client.query('DELETE FROM staff_sync_devices WHERE tenant_id = $1', [tenantA]); - await client.query('DELETE FROM staff_notification_deliveries WHERE id = $1', [deliveryIdA]); - await client.query('DELETE FROM staff_push_subscriptions WHERE id = $1', [subscriptionIdA]); - await client.query('DELETE FROM staff_outbox WHERE id = $1', [eventId]); - await client.query('DELETE FROM staff_professions WHERE id = $1', [professionId]); - await client.query('DELETE FROM staff_employees WHERE id = $1', [employeeIdA]); - }); - await database.close(); + try { + await cleanupAsMigrator(); + } finally { + await database.close(); + } }); it('prevents reads and writes across two tenants', async () => { @@ -253,7 +296,9 @@ describeDatabase('PostgreSQL tenant isolation', () => { }); const cpVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) => - client.query('SELECT device_id FROM staff_sync_device_checkpoints WHERE device_id = $1', [deviceIdA]), + client.query('SELECT device_id FROM staff_sync_device_checkpoints WHERE device_id = $1', [ + deviceIdA, + ]), ); expect(cpVisibleToB.rowCount).toBe(0);