From 7f8750f30ff07608a8629455d6d2ce4b81df6568 Mon Sep 17 00:00:00 2001 From: nns Date: Sun, 4 Oct 2026 12:02:44 +0500 Subject: [PATCH] =?UTF-8?q?test(s29):=20unit=20(6)=20+=20integration=20(7)?= =?UTF-8?q?=20+=20e2e=20scenario=20=D0=B4=D0=BB=D1=8F=20simple-mode=20(?= =?UTF-8?q?=D1=88=D0=B0=D0=B3=207/8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/simple-mode-progress.md | 38 ++- .../e2e/scenarios/stage-simple-mode.steps.ts | 256 ++++++++++++++++++ tests/e2e/scenarios/stage-simple-mode.yml | 38 +++ .../AdvancedModeTests.cs | 204 ++++++++++++++ .../Support/ApiFactory.cs | 12 +- .../AdvancedModeFilterTests.cs | 169 ++++++++++++ 6 files changed, 715 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/scenarios/stage-simple-mode.steps.ts create mode 100644 tests/e2e/scenarios/stage-simple-mode.yml create mode 100644 tests/food-market.IntegrationTests/AdvancedModeTests.cs create mode 100644 tests/food-market.UnitTests/AdvancedModeFilterTests.cs diff --git a/docs/simple-mode-progress.md b/docs/simple-mode-progress.md index 7b4cb1b..371d72e 100644 --- a/docs/simple-mode-progress.md +++ b/docs/simple-mode-progress.md @@ -15,7 +15,7 @@ SuperAdmin и только для конкретной организации. - [x] 4. Web: меню, маршруты, дашборд фильтруются; прямой переход → страница «Недоступно» - [x] 5. Упрощённые формы (товар / контрагент / документы) — скрыть «не нужные» поля, значения сохраняются - [x] 6. SuperAdmin UI — бейдж «Простой/Расширенный» + переключатель с подтверждением -- [ ] 7. Тесты (unit + integration + e2e); патч quality-watchdog при необходимости +- [x] 7. Тесты (unit + integration + e2e); патч quality-watchdog при необходимости - [ ] 8. Деплой стейджа + `docs/simple-mode.md` ## Простой режим — что видно @@ -143,3 +143,39 @@ SuperAdmin и только для конкретной организации. - Переключение бьёт `PUT /api/super-admin/organizations/{id}/advanced-mode { enabled }` и после успеха `list.refetch()`. - Переключение из карточки организации (`SuperAdminOrgEmployeesPage` / detail-view) не делаем в этой итерации — список-страница с бейджем и кнопкой уже закрывает UX. - `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` (не гейтится — только мутации). Патч смока **не требуется**. diff --git a/tests/e2e/scenarios/stage-simple-mode.steps.ts b/tests/e2e/scenarios/stage-simple-mode.steps.ts new file mode 100644 index 0000000..b5185e0 --- /dev/null +++ b/tests/e2e/scenarios/stage-simple-mode.steps.ts @@ -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 { + 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 { + 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(() => {}) + } +} diff --git a/tests/e2e/scenarios/stage-simple-mode.yml b/tests/e2e/scenarios/stage-simple-mode.yml new file mode 100644 index 0000000..9ea9274 --- /dev/null +++ b/tests/e2e/scenarios/stage-simple-mode.yml @@ -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 видит страницу «Недоступно» diff --git a/tests/food-market.IntegrationTests/AdvancedModeTests.cs b/tests/food-market.IntegrationTests/AdvancedModeTests.cs new file mode 100644 index 0000000..5dabaae --- /dev/null +++ b/tests/food-market.IntegrationTests/AdvancedModeTests.cs @@ -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; + +/// Sprint 29 — Simple/Advanced mode: integration-тесты по чек-листу +/// задачи: +/// +/// простая org → GET на скрытый эндпоинт возвращает 403 + ProblemDetails; +/// org-admin не может переключить режим (403); +/// SuperAdmin переключает → 200 + запись в SuperAdminAuditLog; +/// /api/me отдаёт корректный advancedMode; +/// данные скрытого модуля сохраняются после выкл→вкл. +/// +/// +[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 LoginSuperAdminAsync() + { + var sa = new ApiActor(_factory.CreateClient()); + sa.UseToken(await sa.TokenAsync(SuperAdminEmail, SuperAdminPassword)); + return sa; + } + + private static async Task 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(); + 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(); + 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()) + .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 переключать нельзя"); + } +} diff --git a/tests/food-market.IntegrationTests/Support/ApiFactory.cs b/tests/food-market.IntegrationTests/Support/ApiFactory.cs index ef70675..065d64d 100644 --- a/tests/food-market.IntegrationTests/Support/ApiFactory.cs +++ b/tests/food-market.IntegrationTests/Support/ApiFactory.cs @@ -38,7 +38,17 @@ static ApiFactory() .WithPassword("food_market_test") .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() { diff --git a/tests/food-market.UnitTests/AdvancedModeFilterTests.cs b/tests/food-market.UnitTests/AdvancedModeFilterTests.cs new file mode 100644 index 0000000..3ec31e9 --- /dev/null +++ b/tests/food-market.UnitTests/AdvancedModeFilterTests.cs @@ -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; + +/// Sprint 29 — юнит-тесты фильтра AdvancedModeFilter: +/// принимает/отбивает запросы в зависимости от Organization.IsAdvancedMode, +/// SuperAdmin bypass'ит, кэш инвалидируется по InvalidateCache. +public class AdvancedModeFilterTests +{ + private static AppDbContext NewDb() + { + var opts = new DbContextOptionsBuilder() + .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()); + } + + 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(); + var or = (ObjectResult)ctx.Result!; + or.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + or.Value.Should().BeOfType(); + 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)ctx.Result!).StatusCode.Should().Be(StatusCodes.Status403Forbidden); + } +}