// Сценарии офлайн-синхронизации (/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); }); });