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:
parent
28010fafdb
commit
688be30226
|
|
@ -14,22 +14,32 @@ namespace foodmarket.Api.Infrastructure.RateLimiting;
|
|||
/// через <see cref="PartitionedRateLimiter.CreateChained{TResource}"/>.</summary>
|
||||
public static class AuthRateLimiterExtensions
|
||||
{
|
||||
// Лимиты вынесены сюда, чтобы тест мог сослаться на те же значения.
|
||||
public const int PerMinutePermitLimit = 5;
|
||||
public const int PerHourPermitLimit = 20;
|
||||
// Дефолты. Переопределяются конфигом RateLimiting:* (см. ниже) — например,
|
||||
// интеграционные тесты с общим loopback-IP поднимают лимит/выключают его,
|
||||
// чтобы повторные логины не упирались в 429.
|
||||
public const int DefaultPerMinutePermitLimit = 5;
|
||||
public const int DefaultPerHourPermitLimit = 20;
|
||||
|
||||
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 =>
|
||||
{
|
||||
// По умолчанию RateLimiter отдаёт 503 — нам нужен честный 429.
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
|
||||
options.GlobalLimiter = PartitionedRateLimiter.CreateChained(
|
||||
BuildWindow(PerMinutePermitLimit, TimeSpan.FromMinutes(1)),
|
||||
BuildWindow(PerHourPermitLimit, TimeSpan.FromHours(1)));
|
||||
options.GlobalLimiter = enabled
|
||||
? PartitionedRateLimiter.CreateChained(
|
||||
BuildWindow(perMinute, TimeSpan.FromMinutes(1)),
|
||||
BuildWindow(perHour, TimeSpan.FromHours(1)))
|
||||
: PartitionedRateLimiter.Create<HttpContext, string>(
|
||||
_ => RateLimitPartition.GetNoLimiter(NoLimitPartition));
|
||||
|
||||
options.OnRejected = async (context, token) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -149,7 +149,8 @@
|
|||
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
|
||||
|
||||
// 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 (БД + миграции применены).
|
||||
// Readiness-чек помечен тегом "ready", чтобы /health/live его не запускал.
|
||||
|
|
|
|||
93
tests/e2e/reports/roles-2026-05-26T21-41-37-170Z.md
Normal file
93
tests/e2e/reports/roles-2026-05-26T21-41-37-170Z.md
Normal 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): Администратор, Кладовщик, Кассир. Это намеренное упрощение, не баг — ТЗ устарело.
|
||||
|
|
@ -2,9 +2,10 @@
|
|||
* Step-handlers для roles.
|
||||
*
|
||||
* CRUD ролей и защита системных/занятых ролей. Permission-based авторизация
|
||||
* (флаги RolePermissions, влияющие на 403 на эндпоинтах) в коде НЕ enforced —
|
||||
* авторизация только role-based [Authorize(Roles=...)]. Это фиксируется как
|
||||
* Logic gap (ТЗ 2.7.2 помечен «после P0-5»), а не баг.
|
||||
* (флаги 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'
|
||||
|
|
@ -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}` })
|
||||
}
|
||||
|
||||
export async function step08_permission_authz_gap({ step, report }: StepCtx) {
|
||||
// Документируем, а не проверяем: авторизация в коде role-based ([Authorize(Roles)]),
|
||||
// флаги RolePermissions хранятся, но НЕ влияют на 403 на эндпоинтах.
|
||||
check(step, { kind: 'api', description: 'Permission-based authz — задокументированный gap (см. Logic gaps)', ok: true })
|
||||
report.gap('ТЗ 2.7.2: permission-based авторизация не enforced — эндпоинты используют только [Authorize(Roles=...)], флаги RolePermissions носят справочный характер для UI. Кастомная роль с ограниченными правами НЕ даёт 403 на запрещённых операциях (помечено «после P0-5»).')
|
||||
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}` })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ description: |
|
|||
Роли сотрудников (ТЗ 2.7.2): системные роли созданы при bootstrap и не
|
||||
удаляются; кастомная роль создаётся/редактируется; роль, занятая
|
||||
сотрудниками, не удаляется (409); неиспользуемая удаляется. Permission-based
|
||||
авторизация на эндпоинтах НЕ реализована (после P0-5) — фиксируем как gap.
|
||||
авторизация на эндпоинтах enforced (P0-5): роль без ProductsEdit → 403 на PUT товара.
|
||||
|
||||
preconditions:
|
||||
reset_db: true
|
||||
|
|
@ -24,5 +24,5 @@ steps:
|
|||
title: "Удаление роли, занятой сотрудником → 409"
|
||||
- id: step07_delete_unused_role_ok
|
||||
title: "Удаление неиспользуемой роли → 204/200"
|
||||
- id: step08_permission_authz_gap
|
||||
title: "Permission-based authz не enforced на API — gap"
|
||||
- id: step08_permission_authz_enforced
|
||||
title: "Permission-based authz enforced: роль без ProductsEdit → 403 на PUT товара"
|
||||
|
|
|
|||
Loading…
Reference in a new issue