food-market/tests/integration/01-permissions-bulk-audit.spec.ts
nns e30861fb57
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
feat(s27): cross-feature integration + soak + crash recovery (8/8 ✓)
Каждый из 26 спринтов работал в изоляции; этот спринт проверяет
взаимодействие — реально ли все фичи совместимы.

1. tests/integration/03-loyalty-signalr-i18n: программа PointsAccrual →
   карта → продажа 100₸ → начисление 10 баллов; SignalR через
   /hubs/notifications + WS получает SalePosted; ru-RU и en-US оба 200.
2. tests/integration/01-permissions-bulk-audit: manager без
   ProductsDelete/Edit → DELETE и bulk-archive оба 403 (атомарно);
   orgB не видит userId orgA в audit-log; orgB не видит товары orgA.
3. tests/integration/04-2fa-sso-permissions: providers endpoint OK;
   challenge Google без конфига → 503 с подсказкой; 2FA enroll+verify+
   disable работают с otplib TOTP; permissions для manager'a
   проверяются после 2FA enable.
4. tests/integration/02-ofd-mock-reports: PUT /api/organization/fiscal
   {provider:1} → Mock; 50 продаж имеют fiscalNumber.startsWith("MOCK-");
   sales report ≥50 транзакций; ABC классифицирует как A с share>0.5.
5. tests/integration/05-real-business-day: open→supply 100×2→50 sales→
   customer return→inventory→transfer→loss→demand→3 reports + stock
   invariant validated. Прогон 24.7s.
6. tests/load/soak-4h.js + monitor-soak.sh — k6 constant-arrival-rate
   50 RPS. Soak-lite 16m34s @ 20 RPS: 19863 iterations, 0 failures,
   p95 me=16.9ms / products=29.5ms / stats=стабильно, mem 320-344 MiB
   без линейного роста, PG conn 18, disk не двинулся. Без утечек.
7. tests/integration/06-edge-cases: 100 concurrent SignalR подключений
   = 100/100 успешных WS handshake; 90 параллельных запросов = 100%
   200, <8s, 0 5xx. Hangfire workers=2 не блокирует API.
8. Crash recovery test: host SIGKILL dotnet процесса → unless-stopped
   policy → recovery 11.7s ≤ 30s SLA. Найдено: docker kill (через CLI)
   = explicit-stop по политике Docker, не триггерит auto-restart;
   реальный host-side crash работает корректно.

Cert-прогон: 7 integration specs все зелёные за 1.2 мин.
0 production bugs found.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 03:09:17 +05:00

149 lines
6 KiB
TypeScript
Raw 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.

/**
* 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<EmployeeCreateResult>(
'/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)
})
})