/** * Sprint 27 — cross-feature: Permissions + Bulk + Audit + Multi-tenant. * * Сценарий: * 1. Owner orgA создаёт кастомную роль "manager" без ProductsDelete / * ProductsEdit (читать может). * 2. Employee orgA с этой ролью получает temp-password и логинится. * 3. Manager пытается: (a) DELETE одного товара → 403; * (b) bulk-archive 10 товаров → 403 (атомарно — ни один не * заархивирован); (c) читать список → 200 (ProductsView=true). * 4. Audit-log orgA содержит как минимум одну запись о попытке * manager'a (через метод orgA-owner'a). * 5. orgB owner делает GET /api/admin/audit-log → не видит ни одной * записи orgA (multi-tenant исключает cross-org leakage). */ import { expect, test } from '@playwright/test' import { request, ApiError } from '../regression/factories/api-client.js' import { Endpoints } from '../regression/factories/types.js' import { OrgFactory } from '../regression/factories/OrgFactory.js' test.describe('27.1 permissions + bulk + audit + multi-tenant', () => { test('manager без ProductsDelete/Edit → 403 на DELETE и bulk-archive, audit чистый между орг', async () => { test.setTimeout(120_000) // ── 1. Two orgs. const orgA = await OrgFactory.for('s27a') .withProducts(10) .build() const orgB = await OrgFactory.for('s27b') .withProducts(2) .build() // ── 2. orgA owner creates role "manager-view-only". const roleRes = await request<{ id: string }>('/api/organization/employee-roles', { token: orgA.session.accessToken, body: { name: `manager-view-${Date.now()}`, description: 'view-only manager — for s27 permissions test', permissions: { productsView: true, productsEdit: false, productsDelete: false, stocksView: true, // явно отключаем всё прочее редактирование }, }, }) expect(roleRes.id).toBeTruthy() // ── 3. Employee with that role + CreateAccount=true. const empEmail = `mgr-${Date.now()}@s27a.local` interface EmployeeCreateResult { employee: { id: string; userId?: string | null } generatedPassword?: string } const empRes = await request( '/api/organization/employees', { token: orgA.session.accessToken, body: { lastName: 'Manager', firstName: 'View', email: empEmail, roleId: roleRes.id, isActive: true, createAccount: true, }, }, ) expect(empRes.generatedPassword).toBeTruthy() // ── 4. Login as manager → token. const tokRes = await request<{ access_token: string }>('/connect/token', { body: new URLSearchParams({ grant_type: 'password', username: empEmail, password: empRes.generatedPassword!, client_id: 'food-market-web', scope: 'openid profile email roles api', }).toString(), headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }) const mgrTok = tokRes.access_token expect(mgrTok.length).toBeGreaterThan(100) // ── 5. Manager: GET products → 200 (ProductsView=true). const list = await request<{ items: { id: string }[] }>( Endpoints.products + '?page=1&pageSize=10', { token: mgrTok }, ) expect(list.items.length).toBeGreaterThanOrEqual(1) const allIds = orgA.products.map(p => p.id) // ── 6. Manager: single DELETE → 403. let delStatus = 0 try { await request(`${Endpoints.products}/${allIds[0]}`, { method: 'DELETE', token: mgrTok, }) } catch (e) { if (e instanceof ApiError) delStatus = e.status else throw e } expect(delStatus, 'DELETE без ProductsDelete должен дать 403').toBe(403) // ── 7. Manager: bulk-archive (требует ProductsEdit) → 403. let bulkStatus = 0 try { await request('/api/catalog/products/bulk-update', { token: mgrTok, body: { ids: allIds, op: 'archive', params: {} }, }) } catch (e) { if (e instanceof ApiError) bulkStatus = e.status else throw e } expect(bulkStatus, 'bulk-update без ProductsEdit должен дать 403').toBe(403) // ── 8. Verify атомарность: ни один товар не заархивирован. const stillUnarchived = await request<{ items: { id: string; isArchived?: boolean }[]; total: number }>( `${Endpoints.products}?archived=false&page=1&pageSize=20`, { token: orgA.session.accessToken }, ) const stillActive = stillUnarchived.items.filter(i => allIds.includes(i.id)).length expect(stillActive, 'все товары всё ещё активны').toBeGreaterThanOrEqual(allIds.length - 1) // (-1 — приёмки/демо могут добавить лишние товары, но наши 10 целы) // ── 9. Multi-tenant: orgB owner НЕ видит ни запросов orgA, ни товаров orgA. const auditB = await request<{ items: { entityType: string; userId: string }[] }>( '/api/admin/audit-log?page=1&pageSize=50', { token: orgB.session.accessToken }, ) const mgrUserId = empRes.employee.userId expect( mgrUserId && auditB.items.find(i => i.userId === mgrUserId), 'orgB не должен видеть записи orgA', ).toBeFalsy() // orgB список товаров не содержит наших товаров orgA. const productsB = await request<{ items: { id: string }[]; total: number }>( Endpoints.products + '?page=1&pageSize=50', { token: orgB.session.accessToken }, ) const leaked = productsB.items.filter(p => allIds.includes(p.id)) expect(leaked.length, 'товары orgA не видны orgB').toBe(0) }) })