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:
parent
397954be0a
commit
2548bd7bdf
2 changed files with 300 additions and 1 deletions
287
tests/e2e/20-sync-defects.e2e.ts
Normal file
287
tests/e2e/20-sync-defects.e2e.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 принимает курсор больше выданного сервером"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue