test(e2e): двойные ID «владелец/мой» в названиях сценариев и known-defects

Зачем: аудит владельца (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 — те же сценарии, новые имена.
This commit is contained in:
grok 2026-09-26 15:21:22 +07:00
parent 27499da191
commit 0453168d70
6 changed files with 50 additions and 50 deletions

View file

@ -59,7 +59,7 @@ const known = strict
const byId = new Map();
for (const file of results.testResults) {
for (const test of file.assertionResults) {
const id = /^\[([A-Z0-9-]+)\]/.exec(test.title)?.[1] ?? 'UNTAGGED';
const id = /^\[([A-Z0-9/-]+)\]/.exec(test.title)?.[1] ?? 'UNTAGGED';
const entry = byId.get(id) ?? { passed: 0, failed: 0, tests: [] };
if (test.status === 'passed') entry.passed += 1;
else if (test.status === 'failed') entry.failed += 1;

View file

@ -42,7 +42,7 @@ async function accrualsFor(employeeId: string) {
}
describe('Основной API на реальной PostgreSQL', () => {
it('[N1] менеджер создаёт сотрудника через POST /employees → 201', async () => {
it('[K4/N1] менеджер создаёт сотрудника через POST /employees → 201', async () => {
const number = `E2E-${randomUlid().slice(-8)}`;
const response = await api('POST', '/employees', {
token: ctx().managerToken,
@ -59,7 +59,7 @@ describe('Основной API на реальной PostgreSQL', () => {
expect(rows).toHaveLength(1);
});
it('[N2] подтверждение смены, расчёт и утверждение зарплаты проходят (200) и начисляют 8 ч × ставка', async () => {
it('[K3/N2] подтверждение смены, расчёт и утверждение зарплаты проходят (200) и начисляют 8 ч × ставка', async () => {
const [unit] = await createOrgUnits(1);
const [employee] = await importEmployees(1);
await assignHourly(employee!.id, unit!.id);
@ -88,7 +88,7 @@ describe('Основной API на реальной PostgreSQL', () => {
expect(total).toBe(BigInt(8 * SYNTHETIC_RATE_MINOR));
});
it('[N4] пересекающиеся расчёты не начисляют одну смену дважды', async () => {
it('[K5/N4] пересекающиеся расчёты не начисляют одну смену дважды', async () => {
const [unit] = await createOrgUnits(1);
const [employee] = await importEmployees(1);
await assignHourly(employee!.id, unit!.id);
@ -136,7 +136,7 @@ describe('Основной API на реальной PostgreSQL', () => {
);
});
it('[N3] push-подписка на внутренний адрес отклоняется, сервер не подключается к 127.0.0.1', async () => {
it('[K12/N3] push-подписка на внутренний адрес отклоняется, сервер не подключается к 127.0.0.1', async () => {
const [employee] = await importEmployees(1);
const employeeToken = await issueEmployeeToken(employee!.id);
let connections = 0;
@ -227,7 +227,7 @@ describe('Основной API на реальной PostgreSQL', () => {
});
});
it('[F2] standalone без токенов не выдаёт права менеджера анонимному запросу (401)', async () => {
it('[K1/F2] standalone без токенов не выдаёт права менеджера анонимному запросу (401)', async () => {
const portal = await api('GET', '/portal', { base: 'open' });
const payroll = await api('GET', '/payroll', { base: 'open' });
const write = await api('POST', '/professions', {
@ -241,7 +241,7 @@ describe('Основной API на реальной PostgreSQL', () => {
});
});
it('[F3] cookie сессии не содержит сырой токен и отзывается при выходе', async () => {
it('[K10/F3] cookie сессии не содержит сырой токен и отзывается при выходе', async () => {
const login = await api('POST', '/session', { body: { access_token: ctx().managerToken } });
expect(login.status, describeResponse(login)).toBe(200);
const setCookie = login.headers.get('set-cookie') ?? '';
@ -311,7 +311,7 @@ describe('Основной API на реальной PostgreSQL', () => {
expect(response.status, describeResponse(response)).toBe(400);
});
it('[F4] перебор ключей входа ограничивается (429 не позже 30 неверных попыток)', async () => {
it('[K11/F4] перебор ключей входа ограничивается (429 не позже 30 неверных попыток)', async () => {
const statuses: number[] = [];
for (let attempt = 0; attempt < 30; attempt += 1) {
const response = await api('POST', '/session', {

View file

@ -57,7 +57,7 @@ async function pullAll(device: Device, cursor: string | null = null) {
}
describe('Офлайн-синхронизация на реальной PostgreSQL', () => {
it('[SYNC-A] устройство не может само подтвердить смену (status=confirmed отклоняется)', async () => {
it('[K13/SYNC-A] устройство не может само подтвердить смену (status=confirmed отклоняется)', async () => {
const [unit] = await createOrgUnits(1);
const [employee] = await importEmployees(1);
const device = await registerDevice(unit!.id);
@ -86,7 +86,7 @@ describe('Офлайн-синхронизация на реальной PostgreS
).toEqual({ http: 200, result: 'rejected', stored: null });
});
it('[SYNC-B] устройство не может утвердить табель (status=approved отклоняется)', async () => {
it('[K13/SYNC-B] устройство не может утвердить табель (status=approved отклоняется)', async () => {
const [employee] = await importEmployees(1);
const device = await registerDevice();
const entryId = randomUlid();
@ -124,7 +124,7 @@ describe('Офлайн-синхронизация на реальной PostgreS
expect(types.has('employee') && types.has('org_unit')).toBe(true);
});
it('[SYNC-C] начальная загрузка >100 сущностей отдаёт все сущности постранично', async () => {
it('[S5/SYNC-C] начальная загрузка >100 сущностей отдаёт все сущности постранично', async () => {
const employees = await importEmployees(120);
const device = await registerDevice();
const { changes } = await pullAll(device, null);
@ -135,7 +135,7 @@ describe('Офлайн-синхронизация на реальной PostgreS
expect(missing, `не доставлено сотрудников: ${missing} из ${employees.length}`).toBe(0);
});
it('[SYNC-D] изменения, сделанные через портал, попадают в инкрементальный pull', async () => {
it('[S5/SYNC-D] изменения, сделанные через портал, попадают в инкрементальный pull', async () => {
const device = await registerDevice();
// Курсор берём из /sync/status, чтобы сценарий не зависел от начальной загрузки (SYNC-C0).
const status = await api('GET', '/sync/status', { token: device.token });
@ -153,7 +153,7 @@ describe('Офлайн-синхронизация на реальной PostgreS
);
});
it('[SYNC-E] отметка уволенного сотрудника через sync отклоняется', async () => {
it('[S4/SYNC-E] отметка уволенного сотрудника через sync отклоняется', async () => {
const [employee] = await importEmployees(1);
const dismiss = await api('PATCH', `/employees/${employee!.id}`, {
token: ctx().managerToken,
@ -171,7 +171,7 @@ describe('Офлайн-синхронизация на реальной PostgreS
expect(response.body?.results?.[0]?.status, describeResponse(response)).toBe('rejected');
});
it('[SYNC-F] отметка через sync сохраняет точку устройства; чужая точка в payload отклоняется', async () => {
it('[S4/SYNC-F] отметка через sync сохраняет точку устройства; чужая точка в payload отклоняется', async () => {
const [unitA, unitB] = await createOrgUnits(2);
const [employee] = await importEmployees(1);
const device = await registerDevice(unitA!.id);
@ -202,7 +202,7 @@ describe('Офлайн-синхронизация на реальной PostgreS
).toEqual({ ownOrgUnit: unitA!.id, foreign: 'rejected' });
});
it('[SYNC-G] битая команда в пакете отклоняется по отдельности, остальные применяются (не 500)', async () => {
it('[S3/SYNC-G] битая команда в пакете отклоняется по отдельности, остальные применяются (не 500)', async () => {
const [employee] = await importEmployees(1);
const device = await registerDevice();
const good = command('attendance_event', {
@ -225,7 +225,7 @@ describe('Офлайн-синхронизация на реальной PostgreS
).toEqual({ http: 200, statuses: ['applied', 'rejected'] });
});
it('[SYNC-H] регистрация устройства по cookie с чужим Origin отклоняется (CSRF, 403)', async () => {
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] ?? '';
@ -236,7 +236,7 @@ describe('Офлайн-синхронизация на реальной PostgreS
expect(response.status, describeResponse(response)).toBe(403);
});
it('[SYNC-I] без настроенных токенов анонимный запрос не регистрирует устройство (401)', async () => {
it('[K1/SYNC-I] без настроенных токенов анонимный запрос не регистрирует устройство (401)', async () => {
const response = await api('POST', '/sync/devices', {
base: 'open',
body: {
@ -247,7 +247,7 @@ describe('Офлайн-синхронизация на реальной PostgreS
expect(response.status, describeResponse(response)).toBe(401);
});
it('[SYNC-J] отметка через sync вне окна времени (2001 год) отклоняется, как в POST /attendance/events', async () => {
it('[S2/SYNC-J] отметка через sync вне окна времени (2001 год) отклоняется, как в POST /attendance/events', async () => {
const [employee] = await importEmployees(1);
const device = await registerDevice();
const response = await push(device, [

View file

@ -89,7 +89,7 @@ async function ledgerFor(employeeId: string) {
}
describe('Решения владельца: роли, пакеты зарплаты, согласие на биометрию', () => {
it('[OWN-01] менеджер точки A запускает расчёт для точки B → 403', async () => {
it('[D6/OWN-01] менеджер точки A запускает расчёт для точки B → 403', async () => {
const w = await world(false);
const managerA = await grant('point_manager', [w.unitA.id]);
const batch = await createBatch(managerA, w.unitB.id, w.day);
@ -100,7 +100,7 @@ describe('Решения владельца: роли, пакеты зарпла
expect({ batch: batch.status, legacy: legacy.status }).toEqual({ batch: 403, legacy: 403 });
});
it('[OWN-02] менеджер точки отправляет пакет на согласование → 403', async () => {
it('[D6/OWN-02] менеджер точки отправляет пакет на согласование → 403', async () => {
const w = await world(false);
const managerA = await grant('point_manager', [w.unitA.id]);
const created = await createBatch(managerA, w.unitA.id, w.day);
@ -169,7 +169,7 @@ describe('Решения владельца: роли, пакеты зарпла
}).toEqual({ accrual: true, payout: true, editShift: 409, resubmit: 409 });
});
it('[OWN-07] смена изменена после отправки → подписать нельзя (409), пакет возвращён бухгалтеру', async () => {
it('[K19/OWN-07] смена изменена после отправки → подписать нельзя (409), пакет возвращён бухгалтеру', async () => {
const accountant = await grant('accountant');
const director = await grant('director');
const w = await world();
@ -233,7 +233,7 @@ describe('Решения владельца: роли, пакеты зарпла
});
});
it('[OWN-10] сотрудник без согласия на биометрию → сервер сообщает терминалу «регистрация запрещена»', async () => {
it('[K17/OWN-10] сотрудник без согласия на биометрию → сервер сообщает терминалу «регистрация запрещена»', async () => {
const [employee] = await importEmployees(1);
const response = await api('GET', `/terminal/employees/${employee!.id}/enrollment`, {
token: ctx().terminalToken,
@ -242,7 +242,7 @@ describe('Решения владельца: роли, пакеты зарпла
expect(response.body).toMatchObject({ registration_allowed: false, reason: 'consent_missing' });
});
it('[OWN-11] согласие отозвано → статус для терминала меняется', async () => {
it('[K17/OWN-11] согласие отозвано → статус для терминала меняется', async () => {
const [employee] = await importEmployees(1);
const setConsent = (status: 'granted' | 'revoked') =>
api('PUT', `/employees/${employee!.id}/biometric-consent`, {

View file

@ -27,14 +27,14 @@ function environmentOf(service: string): Record<string, string> {
}
describe('deploy/pilot/compose.yaml', () => {
it('[PILOT-1] все подставляемые переменные обязательны: ${VAR:?сообщение}', () => {
it('[K1/PILOT-1] все подставляемые переменные обязательны: ${VAR:?сообщение}', () => {
const interpolations = [...raw.matchAll(/\$\{([^}]*)\}/g)].map((match) => match[1]!);
expect(interpolations.length).toBeGreaterThan(0);
const optional = interpolations.filter((inner) => !/^[A-Z0-9_]+:\?.+$/.test(inner));
expect(optional, 'переменные без :? — пустое значение молча попадёт в контейнер').toEqual([]);
});
it('[PILOT-2] ни один сервис не запускается с NODE_ENV=development', () => {
it('[K2/PILOT-2] ни один сервис не запускается с NODE_ENV=development', () => {
const offenders = Object.keys(compose.services).filter(
(service) => environmentOf(service).NODE_ENV === 'development',
);

View file

@ -1,43 +1,43 @@
{
"comment": "ID сценариев, красный цвет которых сейчас ожидаем (открытые дефекты или ещё не реализованные решения владельца). Когда дефект исправлен и все тесты ID зелёные, scripts/test/run-e2e.mjs падает, пока ID не убран отсюда. Добавлять ID сюда можно только вместе с записью в REPORT.md.",
"comment": "ID сценариев, красный цвет которых сейчас ожидаем (открытые дефекты или ещё не реализованные решения владельца). Когда дефект исправлен и все тесты ID зелёные, scripts/test/run-e2e.mjs падает, пока ID не убран отсюда. Добавлять ID сюда можно только вместе с записью в REPORT.md. Ключи — двойные ID «владелец/мой» (docs/audit/2026-09-26/AUDIT.md и TASK-GROK-2 §2), для сценариев без ID владельца — мой ID.",
"defects": {
"N1": "POST /employees: 42P08 inconsistent types for $2 на реальной PostgreSQL",
"N2": "подтверждение смены / расчёт / утверждение: staff_outbox.occurred_at NULL (23502)",
"N3": "SSRF через push-endpoint на внутренние адреса",
"N4": "двойное начисление при пересекающихся расчётах (сейчас блокирован N2)",
"K4/N1": "POST /employees: 42P08 inconsistent types for $2 на реальной PostgreSQL",
"K3/N2": "подтверждение смены / расчёт / утверждение: staff_outbox.occurred_at NULL (23502)",
"K12/N3": "SSRF через push-endpoint на внутренние адреса",
"K5/N4": "двойное начисление при пересекающихся расчётах (сейчас блокирован N2)",
"N9": "перепривязка чужого push-endpoint",
"N10": "справочники доступны терминалу",
"N11": "импорт с неверным kind → 500",
"N12": "pay-assignment без additionalProperties:false и верхних границ",
"F2": "standalone без токенов даёт права STANDALONE_ACCESS анониму",
"F3": "cookie = сырой токен, logout не отзывает",
"F4": "нет rate limit на вход",
"K1/F2": "standalone без токенов даёт права STANDALONE_ACCESS анониму",
"K10/F3": "cookie = сырой токен, logout не отзывает",
"K11/F4": "нет rate limit на вход",
"F7": "Idempotency-Key не используется",
"F10": "CSRF: мутации по cookie без Origin разрешены",
"SYNC-A": "устройство само подтверждает смену через upsert_work_session",
"SYNC-B": "устройство само утверждает табель через upsert_timesheet_entry",
"K13/SYNC-A": "устройство само подтверждает смену через upsert_work_session",
"K13/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 принимает отметки вне окна времени",
"S5/SYNC-C": "начальная загрузка обрезается на 100 сущностях (сейчас также блокирована SYNC-C0)",
"S5/SYNC-D": "изменения из портала не пишутся в staff_sync_change_log",
"S4/SYNC-E": "отметки уволенных сотрудников принимаются через sync",
"S4/SYNC-F": "sync не сохраняет org_unit_id отметки и принимает чужую точку",
"S3/SYNC-G": "одна битая команда роняет весь пакет (500)",
"S8/SYNC-H": "регистрация устройства по cookie без проверки Origin",
"K1/SYNC-I": "без токенов анонимно регистрируется устройство",
"S2/SYNC-J": "sync принимает отметки вне окна времени",
"SYNC-K": "ack принимает курсор больше выданного сервером",
"PILOT-1": "deploy/pilot/compose.yaml: 14 подстановок без :? (пустые токены/пароли молча проходят)",
"PILOT-2": "deploy/pilot/compose.yaml: app с NODE_ENV=development",
"OWN-01": "решение владельца: роли точки (этап 4 Codex)",
"OWN-02": "решение владельца: пакет отправляет только бухгалтер",
"K1/PILOT-1": "deploy/pilot/compose.yaml: 14 подстановок без :? (пустые токены/пароли молча проходят)",
"K2/PILOT-2": "deploy/pilot/compose.yaml: app с NODE_ENV=development",
"D6/OWN-01": "решение владельца: роли точки (этап 4 Codex)",
"D6/OWN-02": "решение владельца: пакет отправляет только бухгалтер",
"OWN-03": "решение владельца: уведомления директорам",
"OWN-04": "решение владельца: подписывает только директор",
"OWN-05": "решение владельца: ledger пуст до подписи",
"OWN-06": "решение владельца: подпись → ledger, неизменяемость",
"OWN-07": "решение владельца: изменение смены после отправки возвращает пакет",
"K19/OWN-07": "решение владельца: изменение смены после отправки возвращает пакет",
"OWN-08": "решение владельца: возврат с комментарием",
"OWN-09": "решение владельца: смена в чужой точке идёт в зарплату той точки",
"OWN-10": "решение владельца: нет согласия на биометрию → регистрация запрещена",
"OWN-11": "решение владельца: отзыв согласия меняет статус для терминала"
"K17/OWN-10": "решение владельца: нет согласия на биометрию → регистрация запрещена",
"K17/OWN-11": "решение владельца: отзыв согласия меняет статус для терминала"
}
}