test(e2e): сценарии офлайн-синхронизации (/sync/*)

Зачем: бэкенд офлайн-синхронизации (миграция 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 — позитивный контроль отзыва.
This commit is contained in:
grok 2026-09-26 14:27:59 +07:00
parent 518494d03c
commit 32ced4b100
2 changed files with 300 additions and 1 deletions

View file

@ -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<Device> {
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<string, unknown>) {
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);
});
});

View file

@ -13,6 +13,18 @@
"F3": "cookie = сырой токен, logout не отзывает", "F3": "cookie = сырой токен, logout не отзывает",
"F4": "нет rate limit на вход", "F4": "нет rate limit на вход",
"F7": "Idempotency-Key не используется", "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 принимает курсор больше выданного сервером"
} }
} }