Зачем: аудит владельца (docs/audit/2026-09-26/AUDIT.md) стал основным реестром дефектов; без сопоставления исполнители и приёмка говорят о разных ID для одной ошибки (TASK-GROK-2 §2). Что: теги переименованы по таблице владельца ([K3/N2], [K13/SYNC-A], [S5/SYNC-C], [D6/OWN-01], [K17/OWN-10] и т. д.), ключи known-defects.json — так же; N9–N12, F7, F10 остаются одиночными, как в таблице. Сверх таблицы, по тексту AUDIT.md: K1/SYNC-I (анонимный доступ без токенов — это K1 на /sync), K1/PILOT-1 (подстановки без :? в пилотном compose прямо названы в K1), K2/PILOT-2 (NODE_ENV=development в пилоте — суть K2). SYNC-C0, SYNC-K, OWN-03..06/08/09, PILOT-3 ID владельца не имеют. run-e2e принимает «/» в теге. Прогон: 38 red / 9 green, храповик OK — те же сценарии, новые имена.
287 lines
12 KiB
TypeScript
287 lines
12 KiB
TypeScript
// Сценарии офлайн-синхронизации (/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('[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);
|
||
});
|
||
});
|