/** * 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}` }) }