From 2548bd7bdfa0a17bbf63573ed9c4ae18f7783df3 Mon Sep 17 00:00:00 2001 From: grok Date: Sat, 26 Sep 2026 14:27:59 +0700 Subject: [PATCH] =?UTF-8?q?test(e2e):=20=D1=81=D1=86=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B8=20=D0=BE=D1=84=D0=BB=D0=B0=D0=B9=D0=BD-?= =?UTF-8?q?=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20(/sync/*)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем: бэкенд офлайн-синхронизации (миграция 0011) покрыт только юнит-тестами без реальной БД. Сценарии на живом сервере показывают, как терминал реально взаимодействует с сервером, и фиксируют границы доверия к устройству. Проверяется правильное поведение: устройство не подтверждает смены и не утверждает табель сам (SYNC-A/B); начальная загрузка работает (SYNC-C0, сейчас 500 «column status does not exist» — у staff_org_units нет status) и не теряет сущности после 100-й (SYNC-C); правки из портала видны в инкрементальном pull (SYNC-D); отметки уволенных и вне окна времени отклоняются (SYNC-E/J); точка устройства сохраняется, чужая отклоняется (SYNC-F); битая команда не роняет пакет (SYNC-G); CSRF и анонимная регистрация устройств запрещены (SYNC-H/I); ack не принимает курсор из будущего (SYNC-K). SYNC-L — позитивный контроль отзыва. --- tests/e2e/20-sync-defects.e2e.ts | 287 +++++++++++++++++++++++++++++++ tests/e2e/known-defects.json | 14 +- 2 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/20-sync-defects.e2e.ts diff --git a/tests/e2e/20-sync-defects.e2e.ts b/tests/e2e/20-sync-defects.e2e.ts new file mode 100644 index 0000000..3758c80 --- /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('[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('[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('[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('[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('[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('[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('[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('[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('[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('[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/known-defects.json b/tests/e2e/known-defects.json index 92eaf60..49a3fc1 100644 --- a/tests/e2e/known-defects.json +++ b/tests/e2e/known-defects.json @@ -13,6 +13,18 @@ "F3": "cookie = сырой токен, logout не отзывает", "F4": "нет rate limit на вход", "F7": "Idempotency-Key не используется", - "F10": "CSRF: мутации по cookie без Origin разрешены" + "F10": "CSRF: мутации по cookie без Origin разрешены", + "SYNC-A": "устройство само подтверждает смену через upsert_work_session", + "SYNC-B": "устройство само утверждает табель через upsert_timesheet_entry", + "SYNC-C0": "начальная загрузка /sync/pull падает 500: column status does not exist (staff_org_units)", + "SYNC-C": "начальная загрузка обрезается на 100 сущностях (сейчас также блокирована SYNC-C0)", + "SYNC-D": "изменения из портала не пишутся в staff_sync_change_log", + "SYNC-E": "отметки уволенных сотрудников принимаются через sync", + "SYNC-F": "sync не сохраняет org_unit_id отметки и принимает чужую точку", + "SYNC-G": "одна битая команда роняет весь пакет (500)", + "SYNC-H": "регистрация устройства по cookie без проверки Origin", + "SYNC-I": "без токенов анонимно регистрируется устройство", + "SYNC-J": "sync принимает отметки вне окна времени", + "SYNC-K": "ack принимает курсор больше выданного сервером" } }