diff --git a/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs b/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs
index adbeb3d..c82791a 100644
--- a/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs
+++ b/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs
@@ -14,22 +14,32 @@ namespace foodmarket.Api.Infrastructure.RateLimiting;
/// через .
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(
+ _ => RateLimitPartition.GetNoLimiter(NoLimitPartition));
options.OnRejected = async (context, token) =>
{
diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs
index 6dc1efb..13a672b 100644
--- a/src/food-market.api/Program.cs
+++ b/src/food-market.api/Program.cs
@@ -149,7 +149,8 @@
builder.Services.AddScoped();
// 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 его не запускал.
diff --git a/tests/e2e/reports/roles-2026-05-26T21-41-37-170Z.md b/tests/e2e/reports/roles-2026-05-26T21-41-37-170Z.md
new file mode 100644
index 0000000..4d2bcf7
--- /dev/null
+++ b/tests/e2e/reports/roles-2026-05-26T21-41-37-170Z.md
@@ -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): Администратор, Кладовщик, Кассир. Это намеренное упрощение, не баг — ТЗ устарело.
diff --git a/tests/e2e/scenarios/roles.steps.ts b/tests/e2e/scenarios/roles.steps.ts
index 841467d..eef6dcb 100644
--- a/tests/e2e/scenarios/roles.steps.ts
+++ b/tests/e2e/scenarios/roles.steps.ts
@@ -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}` })
}
diff --git a/tests/e2e/scenarios/roles.yml b/tests/e2e/scenarios/roles.yml
index 1cbbb17..ca38303 100644
--- a/tests/e2e/scenarios/roles.yml
+++ b/tests/e2e/scenarios/roles.yml
@@ -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 товара"