test(s29): unit (6) + integration (7) + e2e scenario для simple-mode (шаг 7/8)
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
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

Unit — AdvancedModeFilterTests (6/6, ~3с):
- allow when IsAdvancedMode=true
- 403 ProblemDetails с code=advanced-mode-required when simple
- SuperAdmin bypass
- InvalidateCache делает следующее чтение свежим
- unauthenticated — не трогаем ([Authorize] выше)
- authenticated без org — fail-closed 403

Integration — AdvancedModeTests (7/7, ~25с):
- /api/me.advancedMode=false по умолчанию
- GET на скрытый endpoint в простом режиме → 403 ProblemDetails
- org-admin не может переключить → 403
- SuperAdmin переключает → 200 + audit; идемпотент: повторный → changed=false
- /api/me обновилось, demand открылся
- данные пресета сохраняются через on→off→on
- архивную org переключать нельзя → 409

Доп. фикс: ApiFactory теперь прокидывает ConnectionStrings__Default через
Environment.SetEnvironmentVariable в InitializeAsync. ConfigureAppConfiguration
опаздывает к моменту чтения строки в Program.cs:86 (var poolTunedConnString=...)
— интеграционные тесты падали с «Connection refused к 127.0.0.1:5432». env-var
читает CreateBuilder сразу, строка подключения теперь корректно берётся из
Testcontainers. Это общий фикс для всей сюиты.

E2E — tests/e2e/scenarios/stage-simple-mode.{yml,steps.ts} (7 шагов):
sm01 signup→simple default; sm02 org-admin forbidden; sm03 SuperAdmin
toggle + audit; sm04 /api/me reflects + endpoint opens; sm05 data
survives toggle cycle; sm06 archived→409; sm07 Playwright UI показывает
страницу «Недоступно в простом режиме».

Quality-watchdog: GET /api/catalog/price-types не гейтится (только
мутации) — патч смока не требуется.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-04 12:02:44 +05:00
parent 276cba22f8
commit 7f8750f30f
6 changed files with 715 additions and 2 deletions

View file

@ -15,7 +15,7 @@ SuperAdmin и только для конкретной организации.
- [x] 4. Web: меню, маршруты, дашборд фильтруются; прямой переход → страница «Недоступно» - [x] 4. Web: меню, маршруты, дашборд фильтруются; прямой переход → страница «Недоступно»
- [x] 5. Упрощённые формы (товар / контрагент / документы) — скрыть «не нужные» поля, значения сохраняются - [x] 5. Упрощённые формы (товар / контрагент / документы) — скрыть «не нужные» поля, значения сохраняются
- [x] 6. SuperAdmin UI — бейдж «Простой/Расширенный» + переключатель с подтверждением - [x] 6. SuperAdmin UI — бейдж «Простой/Расширенный» + переключатель с подтверждением
- [ ] 7. Тесты (unit + integration + e2e); патч quality-watchdog при необходимости - [x] 7. Тесты (unit + integration + e2e); патч quality-watchdog при необходимости
- [ ] 8. Деплой стейджа + `docs/simple-mode.md` - [ ] 8. Деплой стейджа + `docs/simple-mode.md`
## Простой режим — что видно ## Простой режим — что видно
@ -143,3 +143,39 @@ SuperAdmin и только для конкретной организации.
- Переключение бьёт `PUT /api/super-admin/organizations/{id}/advanced-mode { enabled }` и после успеха `list.refetch()`. - Переключение бьёт `PUT /api/super-admin/organizations/{id}/advanced-mode { enabled }` и после успеха `list.refetch()`.
- Переключение из карточки организации (`SuperAdminOrgEmployeesPage` / detail-view) не делаем в этой итерации — список-страница с бейджем и кнопкой уже закрывает UX. - Переключение из карточки организации (`SuperAdminOrgEmployeesPage` / detail-view) не делаем в этой итерации — список-страница с бейджем и кнопкой уже закрывает UX.
- `pnpm run build` — ✓ (2.02s, 0 TS errors). - `pnpm run build` — ✓ (2.02s, 0 TS errors).
### 2026-10-04 — Шаг 7 ✓
**Unit — `tests/food-market.UnitTests/AdvancedModeFilterTests.cs`** (6 тестов, все зелёные):
- `Allows_when_IsAdvancedMode_true`
- `Forbids_with_ProblemDetails_when_simple_mode` — проверяет ObjectResult + status 403 + `extensions.code=advanced-mode-required`
- `SuperAdmin_bypasses_check_even_in_simple_org`
- `InvalidateCache_makes_next_read_see_fresh_value`
- `Unauthenticated_request_is_skipped_by_filter`
- `Authenticated_without_org_claim_is_forbidden` (fail-closed)
Запуск: `dotnet test tests/food-market.UnitTests --filter "FullyQualifiedName~AdvancedModeFilterTests"` → 6/6 passed, ~3 с.
**Integration — `tests/food-market.IntegrationTests/AdvancedModeTests.cs`** (7 тестов):
- `Me_returns_advancedMode_false_by_default`
- `Simple_mode_gets_403_ProblemDetails_on_hidden_endpoint`
- `Org_admin_cannot_toggle_advanced_mode` (403)
- `SuperAdmin_can_toggle_and_writes_audit_entry` (идемпотентно: повторный PUT → `changed=false`, в аудит не пишется)
- `Me_advancedMode_switches_with_toggle_and_endpoint_opens`
- `Data_in_hidden_module_survives_toggle_cycle` (пресет виден после `on → off → on`)
- `Toggle_fails_for_archived_org_with_409`
Запуск: `dotnet test tests/food-market.IntegrationTests --filter "FullyQualifiedName~AdvancedModeTests"` → 7/7 passed, ~25 с.
Доп. фикс: `tests/food-market.IntegrationTests/Support/ApiFactory.cs` теперь прокидывает `ConnectionStrings__Default` через `Environment.SetEnvironmentVariable` в `InitializeAsync` — `WebApplicationFactory.ConfigureAppConfiguration` опаздывает к моменту чтения строки в `Program.cs:86`, а env-var читается `CreateBuilder` сразу. Благодаря этому вся интеграционная сюита снова нормально работает на этой dev-vm (не только мои тесты).
**E2E — `tests/e2e/scenarios/stage-simple-mode.yml` + `.steps.ts`** (7 шагов, запускаются после деплоя шага 8):
- `sm01_signup_defaults_to_simple` — /api/me.advancedMode=false + demand 403 с кодом
- `sm02_org_admin_cannot_toggle` — админ орги PUT advanced-mode → 403
- `sm03_superadmin_enables_and_audit` — SuperAdmin включает → 200 + запись в audit; идемпотентный повтор
- `sm04_me_reflects_switch_and_opens_endpoint` — /api/me обновилось, demand открылся
- `sm05_data_survives_toggle_cycle` — пресет виден после on→off→on
- `sm06_archived_org_cannot_toggle` — архивную → 409
- `sm07_ui_shows_not_available_page` — Playwright: простой админ на /sales/demands видит текст «Недоступно в простом режиме»
**Quality-watchdog:** проверка показала, что smoke дёргает только `GET /api/catalog/price-types?pageSize=10` (не гейтится — только мутации). Патч смока **не требуется**.

View file

@ -0,0 +1,256 @@
/**
* Sprint 29 — Simple/Advanced mode: e2e-сценарий для stage.
*/
import { login, makeClient, ADMIN_BASE } from '../lib/api.js'
import type { CheckResult, Step, Report } from '../lib/report.js'
import { chromium, type Browser } from 'playwright'
type Org = {
id: string
email: string
password: string
token: string
name: string
}
type Ctx = {
apiOnly: boolean
ts: number
org?: Org
saToken?: string
presetId?: string
archivedOrg?: Org
}
interface StepCtx { ctx: Ctx; step: Step; report: Report }
const TS = Date.now()
const SA_EMAIL = process.env.SA_EMAIL ?? 'admin@food-market.local'
const SA_PASSWORD = process.env.SA_PASSWORD ?? 'Admin12345!'
function check(step: Step, c: CheckResult) { step.checks.push(c) }
function asString(x: unknown): string {
if (x == null) return ''
if (typeof x === 'string') return x
return JSON.stringify(x)
}
async function bootstrapOrg(suffix: string, phone: string): Promise<Org> {
const api = makeClient()
const email = `stage-sm-${suffix}-${TS}@food-market.local`
const password = 'StageSm12345!'
const name = `Sm ${suffix} ${TS}`
let r = await api.post('/api/auth/signup', { email, password, organizationName: name, phone, plan: 'start' })
for (let i = 0; i < 5 && r.status === 429; i++) {
await new Promise(res => setTimeout(res, 15000))
r = await api.post('/api/auth/signup', { email, password, organizationName: name, phone, plan: 'start' })
}
if (r.status !== 200) throw new Error(`signup ${suffix}: ${r.status}`)
const sess = await login(email, password)
return { id: sess.orgId!, email, password, token: sess.accessToken, name }
}
async function getSaToken(): Promise<string> {
const s = await login(SA_EMAIL, SA_PASSWORD)
return s.accessToken
}
// ---------------------------------------------------------------------------
export async function sm01_signup_defaults_to_simple({ ctx, step }: StepCtx) {
ctx.ts = TS
ctx.org = await bootstrapOrg('a', '+77011190011')
const api = makeClient(ctx.org.token)
const me = await api.get('/api/me')
check(step, {
kind: 'api',
description: 'GET /api/me.advancedMode = false у новой org',
ok: me.status === 200 && me.data?.advancedMode === false,
detail: `status=${me.status} advancedMode=${me.data?.advancedMode}`,
})
const demand = await api.get('/api/sales/demands')
check(step, {
kind: 'api',
description: 'GET /api/sales/demands → 403 ProblemDetails code=advanced-mode-required',
ok: demand.status === 403 && demand.data?.code === 'advanced-mode-required',
detail: `status=${demand.status} code=${demand.data?.code}`,
})
}
export async function sm02_org_admin_cannot_toggle({ ctx, step }: StepCtx) {
if (!ctx.org) { step.status = 'skip'; return }
const api = makeClient(ctx.org.token)
const r = await api.put(`/api/super-admin/organizations/${ctx.org.id}/advanced-mode`, { enabled: true })
check(step, {
kind: 'api',
description: 'Админ орги PUT /api/super-admin/.../advanced-mode → 403',
ok: r.status === 403,
detail: `status=${r.status}`,
})
}
export async function sm03_superadmin_enables_and_audit({ ctx, step }: StepCtx) {
if (!ctx.org) { step.status = 'skip'; return }
ctx.saToken = await getSaToken()
const sa = makeClient(ctx.saToken)
const r = await sa.put(`/api/super-admin/organizations/${ctx.org.id}/advanced-mode`, { enabled: true })
check(step, {
kind: 'api',
description: 'SuperAdmin PUT advanced-mode → 200 + isAdvancedMode=true + changed=true',
ok: r.status === 200 && r.data?.isAdvancedMode === true && r.data?.changed === true,
detail: `status=${r.status} payload=${asString(r.data)}`,
})
// Повтор — changed=false (idempotent).
const again = await sa.put(`/api/super-admin/organizations/${ctx.org.id}/advanced-mode`, { enabled: true })
check(step, {
kind: 'api',
description: 'Повторный PUT → 200 + changed=false',
ok: again.status === 200 && again.data?.changed === false,
detail: `status=${again.status} changed=${again.data?.changed}`,
})
// Audit entry.
const audit = await sa.get(`/api/super-admin/audit-log?organizationId=${ctx.org.id}&pageSize=50`)
const found = (audit.data?.items ?? []).some((x: { actionType: string }) =>
x.actionType === 'EnableAdvancedMode')
check(step, {
kind: 'api',
description: 'super_admin_audit_log содержит EnableAdvancedMode',
ok: audit.status === 200 && found,
detail: `status=${audit.status} found=${found}`,
})
}
export async function sm04_me_reflects_switch_and_opens_endpoint({ ctx, step }: StepCtx) {
if (!ctx.org) { step.status = 'skip'; return }
// Кэш в фильтре инвалидируется на бэкенде; access-token свой же — /api/me теперь advancedMode=true.
const api = makeClient(ctx.org.token)
const me = await api.get('/api/me')
check(step, {
kind: 'api',
description: 'GET /api/me.advancedMode = true после включения',
ok: me.status === 200 && me.data?.advancedMode === true,
detail: `advancedMode=${me.data?.advancedMode}`,
})
const demand = await api.get('/api/sales/demands')
check(step, {
kind: 'api',
description: 'GET /api/sales/demands → не 403 после включения',
ok: demand.status !== 403,
detail: `status=${demand.status}`,
})
}
export async function sm05_data_survives_toggle_cycle({ ctx, step }: StepCtx) {
if (!ctx.org || !ctx.saToken) { step.status = 'skip'; return }
const api = makeClient(ctx.org.token)
const sa = makeClient(ctx.saToken)
// Создаём пресет в advanced-режиме.
const presetName = `preset-sm-${TS}`
const cr = await api.post('/api/user/presets', {
pageKey: 'products',
name: presetName,
configJson: '{"q":"test"}',
})
check(step, {
kind: 'api',
description: 'POST /api/user/presets → 200',
ok: cr.status === 200,
detail: `status=${cr.status}`,
})
// Выключаем — пресет недоступен (403).
const off = await sa.put(`/api/super-admin/organizations/${ctx.org.id}/advanced-mode`, { enabled: false })
check(step, {
kind: 'api',
description: 'Выключение расширенного режима → 200',
ok: off.status === 200,
detail: `status=${off.status}`,
})
const forb = await api.get('/api/user/presets?pageKey=products')
check(step, {
kind: 'api',
description: 'GET /api/user/presets в простом режиме → 403',
ok: forb.status === 403,
detail: `status=${forb.status}`,
})
// Снова включаем — пресет виден.
const on = await sa.put(`/api/super-admin/organizations/${ctx.org.id}/advanced-mode`, { enabled: true })
check(step, {
kind: 'api',
description: 'Повторное включение → 200',
ok: on.status === 200,
detail: `status=${on.status}`,
})
const list = await api.get('/api/user/presets?pageKey=products')
const hasPreset = (list.data?.items ?? list.data ?? []).some?.((p: { name: string }) => p.name === presetName)
|| JSON.stringify(list.data).includes(presetName)
check(step, {
kind: 'api',
description: 'Пресет сохранён и снова виден после вкл→выкл→вкл',
ok: list.status === 200 && !!hasPreset,
detail: `status=${list.status} present=${hasPreset}`,
})
}
export async function sm06_archived_org_cannot_toggle({ ctx, step }: StepCtx) {
if (!ctx.saToken) { step.status = 'skip'; return }
const sa = makeClient(ctx.saToken)
// Отдельная org, чтобы не трогать ту что используется UI-шагом.
ctx.archivedOrg = await bootstrapOrg('arch', '+77011190012')
const arcName = ctx.archivedOrg.name
const arc = await sa.post(`/api/super-admin/organizations/${ctx.archivedOrg.id}/archive`,
{ confirmationName: arcName })
check(step, {
kind: 'api',
description: 'Архивация тестовой org — 204',
ok: arc.status === 204 || arc.status === 200,
detail: `status=${arc.status}`,
})
const put = await sa.put(`/api/super-admin/organizations/${ctx.archivedOrg.id}/advanced-mode`,
{ enabled: true })
check(step, {
kind: 'api',
description: 'PUT advanced-mode на архивную org → 409',
ok: put.status === 409,
detail: `status=${put.status}`,
})
}
export async function sm07_ui_shows_not_available_page({ ctx, step }: StepCtx) {
if (ctx.apiOnly) { step.status = 'skip'; return }
// Отдельная org в простом режиме; UI-проверка через Playwright.
const org = await bootstrapOrg('ui', '+77011190013')
let browser: Browser | undefined
try {
browser = await chromium.launch({ headless: true })
const ctxBrowser = await browser.newContext({ ignoreHTTPSErrors: true })
const page = await ctxBrowser.newPage()
// Прокидываем access-token через localStorage, чтобы миновать форму логина.
await page.goto(ADMIN_BASE + '/login')
await page.evaluate((tok) => {
localStorage.setItem('accessToken', tok)
}, org.token)
await page.goto(ADMIN_BASE + '/sales/demands')
// Текст страницы «Недоступно в простом режиме» рендерит AdvancedModeGuard.
const notAvailable = await page.waitForSelector('text=Недоступно в простом режиме', { timeout: 10_000 }).catch(() => null)
check(step, {
kind: 'ui',
description: 'UI: /sales/demands в простом режиме показывает страницу «Недоступно»',
ok: notAvailable !== null,
detail: notAvailable ? 'guard page rendered' : 'guard page not found within 10s',
})
} finally {
await browser?.close().catch(() => {})
}
}

View file

@ -0,0 +1,38 @@
name: stage-simple-mode
description: |
Sprint 29 — Simple/Advanced mode: все орги по умолчанию в простом
режиме; расширенный включает только SuperAdmin через
/api/super-admin/organizations/{id}/advanced-mode. Данные скрытых
модулей сохраняются и при повторном включении снова видны.
Что проверяем на стейдже (test.admin.food-market.kz):
1. Новая org стартует в простом режиме (/api/me.advancedMode=false).
2. Админ орги не может переключить режим (403).
3. GET на скрытый эндпоинт в простом режиме → 403 ProblemDetails
с code=advanced-mode-required.
4. SuperAdmin переключает → 200 + запись в super_admin_audit_log.
5. После включения /api/me.advancedMode=true и эндпоинт открылся.
6. Данные пресета сохраняются через цикл выкл→вкл (не удаляются).
7. Архивную org переключать нельзя (409).
8. UI-проверка: админ простой орги на /sales/demands видит
страницу «Недоступно в простом режиме», а не белый экран.
preconditions:
reset_db: false
smoke_login_super_admin: false
steps:
- id: sm01_signup_defaults_to_simple
title: Новая org → /api/me.advancedMode=false и demand-эндпоинт 403
- id: sm02_org_admin_cannot_toggle
title: Админ орги PUT /api/super-admin/.../advanced-mode → 403
- id: sm03_superadmin_enables_and_audit
title: SuperAdmin включает → 200 + запись EnableAdvancedMode в super-admin audit-log
- id: sm04_me_reflects_switch_and_opens_endpoint
title: /api/me.advancedMode=true и GET /api/sales/demands → не 403
- id: sm05_data_survives_toggle_cycle
title: Пресет создан в advanced → выкл → вкл → пресет на месте
- id: sm06_archived_org_cannot_toggle
title: Архивную org переключать нельзя (409)
- id: sm07_ui_shows_not_available_page
title: UI: простой админ на /sales/demands видит страницу «Недоступно»

View file

@ -0,0 +1,204 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Sprint 29 — Simple/Advanced mode: integration-тесты по чек-листу
/// задачи:
/// <list type="bullet">
/// <item>простая org → GET на скрытый эндпоинт возвращает 403 + ProblemDetails;</item>
/// <item>org-admin не может переключить режим (403);</item>
/// <item>SuperAdmin переключает → 200 + запись в SuperAdminAuditLog;</item>
/// <item><c>/api/me</c> отдаёт корректный <c>advancedMode</c>;</item>
/// <item>данные скрытого модуля сохраняются после выкл→вкл.</item>
/// </list>
/// </summary>
[Collection(ApiCollection.Name)]
public class AdvancedModeTests
{
private readonly ApiFactory _factory;
public AdvancedModeTests(ApiFactory factory) => _factory = factory;
private const string SuperAdminEmail = "admin@food-market.local";
private const string SuperAdminPassword = "Admin12345!";
private async Task<ApiActor> LoginSuperAdminAsync()
{
var sa = new ApiActor(_factory.CreateClient());
sa.UseToken(await sa.TokenAsync(SuperAdminEmail, SuperAdminPassword));
return sa;
}
private static async Task<string> OrgIdFromMeAsync(ApiActor actor)
{
var me = await actor.GetJsonAsync("/api/me");
return me.GetProperty("orgId").GetString()!;
}
[Fact]
public async Task Me_returns_advancedMode_false_by_default()
{
var admin = new ApiActor(_factory.CreateClient());
await admin.SignupAndLoginAsync($"adv-default-{Guid.NewGuid():N}");
var me = await admin.GetJsonAsync("/api/me");
me.GetProperty("advancedMode").GetBoolean().Should().BeFalse(
"новая организация стартует в простом режиме");
}
[Fact]
public async Task Simple_mode_gets_403_ProblemDetails_on_hidden_endpoint()
{
var admin = new ApiActor(_factory.CreateClient());
await admin.SignupAndLoginAsync($"adv-hidden-{Guid.NewGuid():N}");
using var resp = await admin.Http.GetAsync("/api/sales/demands");
resp.StatusCode.Should().Be(HttpStatusCode.Forbidden,
"demand-эндпоинт скрыт в простом режиме");
// ProblemDetails с нашим кодом.
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("status").GetInt32().Should().Be(403);
body.GetProperty("code").GetString().Should().Be("advanced-mode-required");
}
[Fact]
public async Task Org_admin_cannot_toggle_advanced_mode()
{
var admin = new ApiActor(_factory.CreateClient());
await admin.SignupAndLoginAsync($"adv-noadmin-{Guid.NewGuid():N}");
var orgId = await OrgIdFromMeAsync(admin);
using var resp = await admin.Http.PutAsJsonAsync(
$"/api/super-admin/organizations/{orgId}/advanced-mode",
new { enabled = true });
resp.StatusCode.Should().Be(HttpStatusCode.Forbidden,
"SuperAdmin-эндпоинт недоступен обычному админу орги");
}
[Fact]
public async Task SuperAdmin_can_toggle_and_writes_audit_entry()
{
var admin = new ApiActor(_factory.CreateClient());
await admin.SignupAndLoginAsync($"adv-toggle-{Guid.NewGuid():N}");
var orgId = await OrgIdFromMeAsync(admin);
var sa = await LoginSuperAdminAsync();
using var on = await sa.Http.PutAsJsonAsync(
$"/api/super-admin/organizations/{orgId}/advanced-mode", new { enabled = true });
on.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await on.Content.ReadFromJsonAsync<JsonElement>();
payload.GetProperty("isAdvancedMode").GetBoolean().Should().BeTrue();
payload.GetProperty("changed").GetBoolean().Should().BeTrue();
// Повторный тот же вызов — changed=false, в аудит не пишется.
using var again = await sa.Http.PutAsJsonAsync(
$"/api/super-admin/organizations/{orgId}/advanced-mode", new { enabled = true });
again.StatusCode.Should().Be(HttpStatusCode.OK);
(await again.Content.ReadFromJsonAsync<JsonElement>())
.GetProperty("changed").GetBoolean().Should().BeFalse();
// Audit: SuperAdminAuditLog содержит запись EnableAdvancedMode по этой org.
var auditList = await sa.ListAsync($"/api/super-admin/audit-log?organizationId={orgId}&pageSize=50");
auditList.Should().Contain(e =>
e.GetProperty("actionType").GetString() == "EnableAdvancedMode"
&& e.GetProperty("organizationId").GetString() == orgId);
}
[Fact]
public async Task Me_advancedMode_switches_with_toggle_and_endpoint_opens()
{
var admin = new ApiActor(_factory.CreateClient());
await admin.SignupAndLoginAsync($"adv-switch-{Guid.NewGuid():N}");
var orgId = await OrgIdFromMeAsync(admin);
// Простой режим — demand 403.
(await admin.Http.GetAsync("/api/sales/demands")).StatusCode
.Should().Be(HttpStatusCode.Forbidden);
var sa = await LoginSuperAdminAsync();
(await sa.Http.PutAsJsonAsync(
$"/api/super-admin/organizations/{orgId}/advanced-mode", new { enabled = true }))
.EnsureSuccessStatusCode();
// /api/me теперь advancedMode=true.
var me2 = await admin.GetJsonAsync("/api/me");
me2.GetProperty("advancedMode").GetBoolean().Should().BeTrue();
// Demand-эндпоинт открылся.
var open = await admin.Http.GetAsync("/api/sales/demands");
open.StatusCode.Should().NotBe(HttpStatusCode.Forbidden,
"после включения расширенного режима demand-эндпоинт доступен");
}
[Fact]
public async Task Data_in_hidden_module_survives_toggle_cycle()
{
// Поведение «данные не удаляются» проверяется через UserPreset:
// создаём пресет в advanced-режиме, выключаем → заново включаем →
// пресет на месте.
var admin = new ApiActor(_factory.CreateClient());
await admin.SignupAndLoginAsync($"adv-cycle-{Guid.NewGuid():N}");
var orgId = await OrgIdFromMeAsync(admin);
var sa = await LoginSuperAdminAsync();
(await sa.Http.PutAsJsonAsync(
$"/api/super-admin/organizations/{orgId}/advanced-mode", new { enabled = true }))
.EnsureSuccessStatusCode();
// Создаём пресет.
var presetName = $"preset-{Guid.NewGuid():N}";
using var create = await admin.Http.PostAsJsonAsync("/api/user/presets", new
{
pageKey = "products",
name = presetName,
configJson = "{\"q\":\"test\"}",
});
create.StatusCode.Should().Be(HttpStatusCode.OK);
// Выключаем режим — пресет временно недоступен (403), но не удалён.
(await sa.Http.PutAsJsonAsync(
$"/api/super-admin/organizations/{orgId}/advanced-mode", new { enabled = false }))
.EnsureSuccessStatusCode();
(await admin.Http.GetAsync("/api/user/presets?pageKey=products")).StatusCode
.Should().Be(HttpStatusCode.Forbidden);
// Снова включаем — пресет виден.
(await sa.Http.PutAsJsonAsync(
$"/api/super-admin/organizations/{orgId}/advanced-mode", new { enabled = true }))
.EnsureSuccessStatusCode();
var list = await admin.ListAsync("/api/user/presets?pageKey=products");
list.Should().Contain(e => e.GetProperty("name").GetString() == presetName,
"данные пресета сохраняются при переключении режима");
}
[Fact]
public async Task Toggle_fails_for_archived_org_with_409()
{
var admin = new ApiActor(_factory.CreateClient());
await admin.SignupAndLoginAsync($"adv-arch-{Guid.NewGuid():N}");
var orgId = await OrgIdFromMeAsync(admin);
var sa = await LoginSuperAdminAsync();
// Нужно узнать имя для подтверждения архивирования.
var detail = await sa.GetJsonAsync($"/api/super-admin/organizations/{orgId}");
var name = detail.GetProperty("name").GetString()!;
(await sa.Http.PostAsJsonAsync(
$"/api/super-admin/organizations/{orgId}/archive", new { confirmationName = name }))
.EnsureSuccessStatusCode();
using var put = await sa.Http.PutAsJsonAsync(
$"/api/super-admin/organizations/{orgId}/advanced-mode", new { enabled = true });
put.StatusCode.Should().Be(HttpStatusCode.Conflict,
"архивную org переключать нельзя");
}
}

View file

@ -38,7 +38,17 @@ static ApiFactory()
.WithPassword("food_market_test") .WithPassword("food_market_test")
.Build(); .Build();
public async Task InitializeAsync() => await _db.StartAsync(); public async Task InitializeAsync()
{
await _db.StartAsync();
// Program.cs читает ConnectionStrings:Default из builder.Configuration
// ЕЩЁ ДО сборки host'а — поэтому ConfigureAppConfiguration ниже успевает
// только для последующих читателей (например, Hangfire на строке 395).
// Чтобы pool-tuned conn string (строка 86) взял значение контейнера —
// прокидываем через env-var, которую CreateBuilder подхватывает в самом
// начале. Приоритет Environment > appsettings.Development.json.
Environment.SetEnvironmentVariable("ConnectionStrings__Default", _db.GetConnectionString());
}
public new async Task DisposeAsync() public new async Task DisposeAsync()
{ {

View file

@ -0,0 +1,169 @@
using System.Security.Claims;
using FluentAssertions;
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Domain.Organizations;
using foodmarket.Infrastructure.Persistence;
using foodmarket.UnitTests.Support;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Sprint 29 — юнит-тесты фильтра <c>AdvancedModeFilter</c>:
/// принимает/отбивает запросы в зависимости от <c>Organization.IsAdvancedMode</c>,
/// SuperAdmin bypass'ит, кэш инвалидируется по <c>InvalidateCache</c>.</summary>
public class AdvancedModeFilterTests
{
private static AppDbContext NewDb()
{
var opts = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"advmode-{Guid.NewGuid():N}")
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.InMemoryEventId.TransactionIgnoredWarning))
.Options;
// Фильтр читает Organizations через IgnoreQueryFilters, но AppDbContext
// всё равно нужен ITenantContext (нулевой использовать нельзя — выбросит
// при OnModelCreating). Для теста достаточно IsSuperAdmin=true, чтобы
// query-filter не блокировал запись.
var tenant = new FakeTenantContext { IsSuperAdmin = true };
return new AppDbContext(opts, tenant);
}
private static AuthorizationFilterContext MakeCtx(ClaimsPrincipal user, out HttpContext http)
{
http = new DefaultHttpContext { User = user };
http.Request.Path = "/api/sales/demands";
var actionCtx = new ActionContext(http, new RouteData(), new ActionDescriptor());
return new AuthorizationFilterContext(actionCtx, new List<IFilterMetadata>());
}
private static ClaimsPrincipal UserWithOrg(Guid orgId, string role = "Admin")
{
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()),
new Claim(HttpContextTenantContext.OrganizationClaim, orgId.ToString()),
new Claim(ClaimTypes.Role, role),
}, authenticationType: "Test");
return new ClaimsPrincipal(identity);
}
[Fact]
public async Task Allows_when_IsAdvancedMode_true()
{
var orgId = Guid.NewGuid();
AdvancedModeFilter.InvalidateCache(orgId);
using var db = NewDb();
db.Organizations.Add(new Organization { Id = orgId, Name = "A", IsAdvancedMode = true });
await db.SaveChangesAsync();
var filter = new AdvancedModeFilter(db);
var ctx = MakeCtx(UserWithOrg(orgId), out _);
await filter.OnAuthorizationAsync(ctx);
ctx.Result.Should().BeNull("advanced=true → фильтр пропускает запрос");
}
[Fact]
public async Task Forbids_with_ProblemDetails_when_simple_mode()
{
var orgId = Guid.NewGuid();
AdvancedModeFilter.InvalidateCache(orgId);
using var db = NewDb();
db.Organizations.Add(new Organization { Id = orgId, Name = "A", IsAdvancedMode = false });
await db.SaveChangesAsync();
var filter = new AdvancedModeFilter(db);
var ctx = MakeCtx(UserWithOrg(orgId), out _);
await filter.OnAuthorizationAsync(ctx);
ctx.Result.Should().BeOfType<ObjectResult>();
var or = (ObjectResult)ctx.Result!;
or.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
or.Value.Should().BeOfType<ProblemDetails>();
var pd = (ProblemDetails)or.Value!;
pd.Extensions.Should().ContainKey("code");
pd.Extensions["code"].Should().Be("advanced-mode-required");
}
[Fact]
public async Task SuperAdmin_bypasses_check_even_in_simple_org()
{
var orgId = Guid.NewGuid();
AdvancedModeFilter.InvalidateCache(orgId);
using var db = NewDb();
db.Organizations.Add(new Organization { Id = orgId, Name = "A", IsAdvancedMode = false });
await db.SaveChangesAsync();
var filter = new AdvancedModeFilter(db);
var ctx = MakeCtx(UserWithOrg(orgId, role: "SuperAdmin"), out _);
await filter.OnAuthorizationAsync(ctx);
ctx.Result.Should().BeNull("SuperAdmin пропускается независимо от режима org");
}
[Fact]
public async Task InvalidateCache_makes_next_read_see_fresh_value()
{
var orgId = Guid.NewGuid();
AdvancedModeFilter.InvalidateCache(orgId);
using var db = NewDb();
var org = new Organization { Id = orgId, Name = "A", IsAdvancedMode = false };
db.Organizations.Add(org);
await db.SaveChangesAsync();
var filter = new AdvancedModeFilter(db);
// Первый вызов — фильтр отказывает.
var ctx1 = MakeCtx(UserWithOrg(orgId), out _);
await filter.OnAuthorizationAsync(ctx1);
ctx1.Result.Should().NotBeNull();
// Переключаем в БД, инвалидируем кэш — следующий вызов видит правду.
org.IsAdvancedMode = true;
await db.SaveChangesAsync();
AdvancedModeFilter.InvalidateCache(orgId);
var ctx2 = MakeCtx(UserWithOrg(orgId), out _);
await filter.OnAuthorizationAsync(ctx2);
ctx2.Result.Should().BeNull("после инвалидации кэша фильтр читает свежее значение");
}
[Fact]
public async Task Unauthenticated_request_is_skipped_by_filter()
{
using var db = NewDb();
var filter = new AdvancedModeFilter(db);
var ctx = MakeCtx(new ClaimsPrincipal(new ClaimsIdentity()), out _);
await filter.OnAuthorizationAsync(ctx);
ctx.Result.Should().BeNull(
"атрибут гейтит только по режиму; аутентификацию ловит [Authorize] выше");
}
[Fact]
public async Task Authenticated_without_org_claim_is_forbidden()
{
using var db = NewDb();
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()),
new Claim(ClaimTypes.Role, "Admin"),
// Нет org_id — fail-closed.
}, authenticationType: "Test");
var user = new ClaimsPrincipal(identity);
var filter = new AdvancedModeFilter(db);
var ctx = MakeCtx(user, out _);
await filter.OnAuthorizationAsync(ctx);
ctx.Result.Should().BeOfType<ObjectResult>();
((ObjectResult)ctx.Result!).StatusCode.Should().Be(StatusCodes.Status403Forbidden);
}
}