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>
This commit is contained in:
nns 2026-05-27 02:41:52 +05:00
parent 28010fafdb
commit 688be30226
5 changed files with 152 additions and 19 deletions

View file

@ -14,22 +14,32 @@ namespace foodmarket.Api.Infrastructure.RateLimiting;
/// через <see cref="PartitionedRateLimiter.CreateChained{TResource}"/>.</summary> /// через <see cref="PartitionedRateLimiter.CreateChained{TResource}"/>.</summary>
public static class AuthRateLimiterExtensions public static class AuthRateLimiterExtensions
{ {
// Лимиты вынесены сюда, чтобы тест мог сослаться на те же значения. // Дефолты. Переопределяются конфигом RateLimiting:* (см. ниже) — например,
public const int PerMinutePermitLimit = 5; // интеграционные тесты с общим loopback-IP поднимают лимит/выключают его,
public const int PerHourPermitLimit = 20; // чтобы повторные логины не упирались в 429.
public const int DefaultPerMinutePermitLimit = 5;
public const int DefaultPerHourPermitLimit = 20;
private const string NoLimitPartition = "__not-an-auth-endpoint"; private const string NoLimitPartition = "__not-an-auth-endpoint";
public static IServiceCollection AddAuthRateLimiting(this IServiceCollection services) public static IServiceCollection AddAuthRateLimiting(this IServiceCollection services, IConfiguration config)
{ {
var section = config.GetSection("RateLimiting");
var enabled = section.GetValue("Enabled", true);
var perMinute = section.GetValue("PerMinute", DefaultPerMinutePermitLimit);
var perHour = section.GetValue("PerHour", DefaultPerHourPermitLimit);
services.AddRateLimiter(options => services.AddRateLimiter(options =>
{ {
// По умолчанию RateLimiter отдаёт 503 — нам нужен честный 429. // По умолчанию RateLimiter отдаёт 503 — нам нужен честный 429.
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.GlobalLimiter = PartitionedRateLimiter.CreateChained( options.GlobalLimiter = enabled
BuildWindow(PerMinutePermitLimit, TimeSpan.FromMinutes(1)), ? PartitionedRateLimiter.CreateChained(
BuildWindow(PerHourPermitLimit, TimeSpan.FromHours(1))); BuildWindow(perMinute, TimeSpan.FromMinutes(1)),
BuildWindow(perHour, TimeSpan.FromHours(1)))
: PartitionedRateLimiter.Create<HttpContext, string>(
_ => RateLimitPartition.GetNoLimiter(NoLimitPartition));
options.OnRejected = async (context, token) => options.OnRejected = async (context, token) =>
{ {

View file

@ -149,7 +149,8 @@
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>(); builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
// Anti-brute-force на /connect/token и /api/auth/signup (5/мин + 20/час на IP). // Anti-brute-force на /connect/token и /api/auth/signup (5/мин + 20/час на IP).
builder.Services.AddAuthRateLimiting(); // Лимиты конфигурируемы через RateLimiting:* (PerMinute/PerHour/Enabled).
builder.Services.AddAuthRateLimiting(builder.Configuration);
// Health-пробы: liveness (процесс жив) и readiness (БД + миграции применены). // Health-пробы: liveness (процесс жив) и readiness (БД + миграции применены).
// Readiness-чек помечен тегом "ready", чтобы /health/live его не запускал. // Readiness-чек помечен тегом "ready", чтобы /health/live его не запускал.

View file

@ -0,0 +1,93 @@
# E2E report: roles
Запущен: 2026-05-26T21:41:28.548Z
Длительность: 5.4с
**Итог:** 8 ✓ / 0 ✗ / 0 ⚠ / 0 ◯ (всего 8)
## ✓ Step step01_bootstrap: Орг A + логин админа
Длительность: 3002мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Орг A + админ готовы | ✓ |
## ✓ Step step02_system_roles_exist: Системные роли (IsSystem=true) созданы — минимум 4
Длительность: 216мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Системные роли (ядро Администратор/Кладовщик/Кассир) присутствуют, не удаляемы | ✓ system=[Администратор, Кладовщик, Кассир] |
## ✓ Step step03_create_custom_role: Создание кастомной роли с правами → права сохранены
Длительность: 194мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Создание кастомной роли → 200/201 | ✓ status=201 |
| api | Права сохранены (productsView=true, productsEdit=false) | ✓ {"productsView":true,"productsEdit":false,"productsDelete":false,"productGroupsManage":false,"priceTypesManage":false,"unitsManage":false,"suppliesView":true,"suppliesEdit":false,"suppliesPost":false, |
## ✓ Step step04_update_permissions: Изменение прав роли применяется (PUT)
Длительность: 180мс
| Тип | Проверка | Результат |
|---|---|---|
| api | PUT прав роли → 200/204 | ✓ status=204 |
| api | Новое право productsEdit=true применилось | ✓ {"productsView":true,"productsEdit":true,"productsDelete":false,"productGroupsManage":false,"priceTypesManage":false,"unitsManage":false,"suppliesView":true,"suppliesEdit":false,"suppliesPost":false," |
## ✓ Step step05_delete_system_role_409: Удаление системной роли → 409
Длительность: 56мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Системная роль найдена | ✓ Администратор |
| api | DELETE системной роли → 409 | ✓ status=409 |
## ✓ Step step06_delete_role_in_use_409: Удаление роли, занятой сотрудником → 409
Длительность: 447мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Сотрудник с кастомной ролью создан | ✓ status=200 |
| api | DELETE занятой роли → 409 | ✓ status=409 |
## ✓ Step step07_delete_unused_role_ok: Удаление неиспользуемой роли → 204/200
Длительность: 61мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Неиспользуемая роль создана | ✓ |
| api | DELETE неиспользуемой роли → 200/204 | ✓ status=204 |
## ✓ Step step08_permission_authz_enforced: Permission-based authz enforced: роль без ProductsEdit → 403 на PUT товара
Длительность: 1207мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Роль «только просмотр» создана | ✓ status=201 |
| api | Сотрудник с логином и ролью создан | ✓ status=200 |
| api | PUT товара без ProductsEdit → 403 | ✓ status=403 |
| api | GET товаров с ProductsView → 200 | ✓ status=200 |
## Summary
- Passed: 8
- Failed: 0
- Warnings: 0
- Skipped: 0
## Critical bugs
Нет.
## Logic gaps
- ТЗ 2.7.2 ожидает 4-6 системных ролей, реально 3 (Phase4b_RolesSimplify): Администратор, Кладовщик, Кассир. Это намеренное упрощение, не багТЗ устарело.

View file

@ -2,9 +2,10 @@
* Step-handlers для roles. * Step-handlers для roles.
* *
* CRUD ролей и защита системных/занятых ролей. Permission-based авторизация * CRUD ролей и защита системных/занятых ролей. Permission-based авторизация
* (флаги RolePermissions, влияющие на 403 на эндпоинтах) в коде НЕ enforced * (флаги RolePermissions 403 на эндпоинтах) enforced с P0-5: эндпоинты
* авторизация только role-based [Authorize(Roles=...)]. Это фиксируется как * каталога/документов гейтятся [RequiresPermission("...")], проверяющим флаги
* Logic gap (ТЗ 2.7.2 помечен «после P0-5»), а не баг. * роли сотрудника. step08 это и проверяет: роль без ProductsEdit 403 на PUT
* товара, при этом GET (ProductsView) проходит.
*/ */
import { login, makeClient } from '../lib/api.js' import { login, makeClient } from '../lib/api.js'
import type { CheckResult, Step, Report } from '../lib/report.js' import type { CheckResult, Step, Report } from '../lib/report.js'
@ -108,9 +109,37 @@ export async function step07_delete_unused_role_ok({ ctx, step }: StepCtx) {
check(step, { kind: 'api', description: 'DELETE неиспользуемой роли → 200/204', ok: okNoContent(r.status), detail: `status=${r.status}` }) check(step, { kind: 'api', description: 'DELETE неиспользуемой роли → 200/204', ok: okNoContent(r.status), detail: `status=${r.status}` })
} }
export async function step08_permission_authz_gap({ step, report }: StepCtx) { export async function step08_permission_authz_enforced({ ctx, step, report }: StepCtx) {
// Документируем, а не проверяем: авторизация в коде role-based ([Authorize(Roles)]), // P0-5: проверяем, что флаги роли реально гейтят эндпоинты. Кастомная роль
// флаги RolePermissions хранятся, но НЕ влияют на 403 на эндпоинтах. // (без Identity-маппинга) с ProductsView, но БЕЗ ProductsEdit:
check(step, { kind: 'api', description: 'Permission-based authz — задокументированный gap (см. Logic gaps)', ok: true }) // PUT товара → 403, GET товаров → 200.
report.gap('ТЗ 2.7.2: permission-based авторизация не enforced — эндпоинты используют только [Authorize(Roles=...)], флаги RolePermissions носят справочный характер для UI. Кастомная роль с ограниченными правами НЕ даёт 403 на запрещённых операциях (помечено «после P0-5»).') 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}` })
} }

View file

@ -3,7 +3,7 @@ description: |
Роли сотрудников (ТЗ 2.7.2): системные роли созданы при bootstrap и не Роли сотрудников (ТЗ 2.7.2): системные роли созданы при bootstrap и не
удаляются; кастомная роль создаётся/редактируется; роль, занятая удаляются; кастомная роль создаётся/редактируется; роль, занятая
сотрудниками, не удаляется (409); неиспользуемая удаляется. Permission-based сотрудниками, не удаляется (409); неиспользуемая удаляется. Permission-based
авторизация на эндпоинтах НЕ реализована (после P0-5) — фиксируем как gap. авторизация на эндпоинтах enforced (P0-5): роль без ProductsEdit → 403 на PUT товара.
preconditions: preconditions:
reset_db: true reset_db: true
@ -24,5 +24,5 @@ steps:
title: "Удаление роли, занятой сотрудником → 409" title: "Удаление роли, занятой сотрудником → 409"
- id: step07_delete_unused_role_ok - id: step07_delete_unused_role_ok
title: "Удаление неиспользуемой роли → 204/200" title: "Удаление неиспользуемой роли → 204/200"
- id: step08_permission_authz_gap - id: step08_permission_authz_enforced
title: "Permission-based authz не enforced на API — gap" title: "Permission-based authz enforced: роль без ProductsEdit → 403 на PUT товара"