food-market/tests/e2e/scenarios/roles.steps.ts
nns 688be30226 test(e2e): roles step08 проверяет permission-enforcement + rate-limit конфигурируем
- roles.steps.ts step08: было «задокументированный gap», стало реальная
  проверка — кастомная роль без ProductsEdit → 403 на PUT товара, GET → 200.
  Сценарий roles зелёный 8/8.
- RateLimiting:* конфиг (Enabled/PerMinute/PerHour): тесты с общим loopback-IP
  поднимают/выключают лимит, чтобы повторные логины не упирались в 429.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:41:52 +05:00

146 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Step-handlers для roles.
*
* CRUD ролей и защита системных/занятых ролей. Permission-based авторизация
* (флаги RolePermissions → 403 на эндпоинтах) enforced с P0-5: эндпоинты
* каталога/документов гейтятся [RequiresPermission("...")], проверяющим флаги
* роли сотрудника. step08 это и проверяет: роль без ProductsEdit → 403 на PUT
* товара, при этом GET (ProductsView) проходит.
*/
import { login, makeClient } from '../lib/api.js'
import type { CheckResult, Step, Report } from '../lib/report.js'
import type { AxiosInstance } from 'axios'
const TS = Date.now()
interface Ctx { apiOnly: boolean; sa?: string; token?: string; customRoleId?: string; throwawayRoleId?: string }
interface StepCtx { ctx: Ctx; step: Step; report: Report }
function check(step: Step, c: CheckResult) { step.checks.push(c) }
function asString(x: unknown): string { try { return JSON.stringify(x).slice(0, 200) } catch { return String(x) } }
const created = (s: number) => s === 200 || s === 201
const okNoContent = (s: number) => s === 200 || s === 204
async function listRoles(api: AxiosInstance) {
const r = await api.get('/api/organization/employee-roles')
return r.data?.items ?? r.data ?? []
}
export async function step01_bootstrap({ ctx, step, report }: StepCtx) {
ctx.sa = (await login('admin@food-market.local', 'Admin12345!')).accessToken
const org = await makeClient(ctx.sa).post('/api/super-admin/organizations', {
org: { name: `Roles ${TS}`, countryCode: 'KZ', bin: null, address: null, phone: null, email: null, defaultCurrencyId: null, accountOwnerUserId: null },
adminLastName: 'Roles', adminFirstName: 'Admin', adminEmail: `roles-${TS}@example.kz`, adminPosition: null,
})
if (org.status !== 200) { report.bug({ step: '01', severity: 'critical', title: 'bootstrap орг', detail: asString(org.data) }); return }
ctx.token = (await login(org.data.adminEmail, org.data.adminTempPassword)).accessToken
check(step, { kind: 'api', description: 'Орг A + админ готовы', ok: !!ctx.token })
}
export async function step02_system_roles_exist({ ctx, step, report }: StepCtx) {
const roles = await listRoles(makeClient(ctx.token!))
const system = roles.filter((r: { isSystem: boolean }) => r.isSystem)
const names: string[] = system.map((r: { name: string }) => r.name)
// Набор системных ролей намеренно упрощён до 3 (миграция Phase4b_RolesSimplify):
// Администратор / Кладовщик / Кассир. ТЗ 2.7.2 говорит «4-6» — иллюстративно,
// до упрощения. Проверяем ядро ролей, а расхождение с ТЗ фиксируем как note.
const core = ['Администратор', 'Кладовщик', 'Кассир']
const missing = core.filter((n) => !names.includes(n))
check(step, { kind: 'api', description: 'Системные роли (ядро Администратор/Кладовщик/Кассир) присутствуют, не удаляемы',
ok: system.length >= 3 && missing.length === 0, detail: `system=[${names.join(', ')}]` })
report.gap(`ТЗ 2.7.2 ожидает 4-6 системных ролей, реально 3 (Phase4b_RolesSimplify): ${names.join(', ')}. Это намеренное упрощение, не багТЗ устарело.`)
}
export async function step03_create_custom_role({ ctx, step, report }: StepCtx) {
const api = makeClient(ctx.token!)
const r = await api.post('/api/organization/employee-roles', {
name: `Менеджер по продажам ${TS}`, description: 'кастомная',
permissions: { productsView: true, suppliesView: true, counterpartiesView: true },
})
check(step, { kind: 'api', description: 'Создание кастомной роли → 200/201', ok: created(r.status), detail: `status=${r.status}` })
ctx.customRoleId = r.data?.id
if (!ctx.customRoleId) { report.bug({ step: '03', severity: 'high', title: 'Роль не создана', detail: asString(r.data) }); return }
const got = (await listRoles(api)).find((x: { id: string }) => x.id === ctx.customRoleId)
check(step, { kind: 'api', description: 'Права сохранены (productsView=true, productsEdit=false)',
ok: got?.permissions?.productsView === true && got?.permissions?.productsEdit === false, detail: asString(got?.permissions) })
}
export async function step04_update_permissions({ ctx, step }: StepCtx) {
if (!ctx.customRoleId) { step.status = 'skip'; return }
const api = makeClient(ctx.token!)
const r = await api.put(`/api/organization/employee-roles/${ctx.customRoleId}`, {
name: `Менеджер по продажам ${TS}`, description: 'обновлено',
permissions: { productsView: true, productsEdit: true, suppliesView: true },
})
check(step, { kind: 'api', description: 'PUT прав роли → 200/204', ok: okNoContent(r.status), detail: `status=${r.status}` })
const got = (await listRoles(api)).find((x: { id: string }) => x.id === ctx.customRoleId)
check(step, { kind: 'api', description: 'Новое право productsEdit=true применилось', ok: got?.permissions?.productsEdit === true, detail: asString(got?.permissions) })
}
export async function step05_delete_system_role_409({ ctx, step }: StepCtx) {
const api = makeClient(ctx.token!)
const sys = (await listRoles(api)).find((r: { isSystem: boolean }) => r.isSystem)
check(step, { kind: 'api', description: 'Системная роль найдена', ok: !!sys, detail: sys?.name })
if (!sys) return
const r = await api.delete(`/api/organization/employee-roles/${sys.id}`)
check(step, { kind: 'api', description: 'DELETE системной роли → 409', ok: r.status === 409, detail: `status=${r.status}` })
}
export async function step06_delete_role_in_use_409({ ctx, step, report }: StepCtx) {
if (!ctx.customRoleId) { step.status = 'skip'; return }
const api = makeClient(ctx.token!)
// Назначаем кастомную роль сотруднику (без учётки).
const emp = await api.post('/api/organization/employees', {
lastName: 'Ролевой', firstName: 'Сотр', roleId: ctx.customRoleId, isActive: true, createAccount: false,
})
check(step, { kind: 'api', description: 'Сотрудник с кастомной ролью создан', ok: created(emp.status), detail: `status=${emp.status}` })
const r = await api.delete(`/api/organization/employee-roles/${ctx.customRoleId}`)
check(step, { kind: 'api', description: 'DELETE занятой роли → 409', ok: r.status === 409, detail: `status=${r.status}` })
if (r.status !== 409) report.bug({ step: '06', severity: 'high', title: 'Роль, занятая сотрудником, удаляется', detail: `status=${r.status}` })
}
export async function step07_delete_unused_role_ok({ ctx, step }: StepCtx) {
const api = makeClient(ctx.token!)
const made = await api.post('/api/organization/employee-roles', { name: `Временная ${TS}`, description: null, permissions: { productsView: true } })
ctx.throwawayRoleId = made.data?.id
check(step, { kind: 'api', description: 'Неиспользуемая роль создана', ok: !!ctx.throwawayRoleId })
if (!ctx.throwawayRoleId) return
const r = await api.delete(`/api/organization/employee-roles/${ctx.throwawayRoleId}`)
check(step, { kind: 'api', description: 'DELETE неиспользуемой роли → 200/204', ok: okNoContent(r.status), detail: `status=${r.status}` })
}
export async function step08_permission_authz_enforced({ ctx, step, report }: StepCtx) {
// P0-5: проверяем, что флаги роли реально гейтят эндпоинты. Кастомная роль
// (без Identity-маппинга) с ProductsView, но БЕЗ ProductsEdit:
// PUT товара → 403, GET товаров → 200.
const api = makeClient(ctx.token!)
const roleRes = await api.post('/api/organization/employee-roles', {
name: `Только-просмотр ${TS}`, description: 'productsView без productsEdit',
permissions: { productsView: true, productsEdit: false },
})
const roleId = roleRes.data?.id
check(step, { kind: 'api', description: 'Роль «только просмотр» создана', ok: created(roleRes.status) && !!roleId, detail: `status=${roleRes.status}` })
if (!roleId) { report.bug({ step: '08', severity: 'high', title: 'Роль не создана', detail: asString(roleRes.data) }); return }
const empEmail = `viewer-${TS}@example.kz`
const empRes = await api.post('/api/organization/employees', {
lastName: 'Просмотров', firstName: 'Вью', roleId, isActive: true, createAccount: true, email: empEmail,
})
const tempPwd = empRes.data?.generatedPassword
check(step, { kind: 'api', description: 'Сотрудник с логином и ролью создан', ok: created(empRes.status) && !!tempPwd, detail: `status=${empRes.status}` })
if (!tempPwd) { report.bug({ step: '08', severity: 'high', title: 'Логин сотрудника не выдан', detail: asString(empRes.data) }); return }
const viewerToken = (await login(empEmail, tempPwd)).accessToken
const viewer = makeClient(viewerToken)
// Авторизация проверяется до биндинга тела — несуществующий id даёт 403,
// если права нет (а не 404).
const fakeId = '00000000-0000-0000-0000-000000000001'
const putRes = await viewer.put(`/api/catalog/products/${fakeId}`, {})
check(step, { kind: 'api', description: 'PUT товара без ProductsEdit → 403', ok: putRes.status === 403, detail: `status=${putRes.status}` })
if (putRes.status !== 403) report.bug({ step: '08', severity: 'critical', title: 'Permission-гейт не сработал: роль без ProductsEdit правит товары', detail: `status=${putRes.status}` })
const getRes = await viewer.get('/api/catalog/products')
check(step, { kind: 'api', description: 'GET товаров с ProductsView → 200', ok: getRes.status === 200, detail: `status=${getRes.status}` })
}