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
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:
parent
276cba22f8
commit
7f8750f30f
|
|
@ -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` (не гейтится — только мутации). Патч смока **не требуется**.
|
||||
|
|
|
|||
256
tests/e2e/scenarios/stage-simple-mode.steps.ts
Normal file
256
tests/e2e/scenarios/stage-simple-mode.steps.ts
Normal 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(() => {})
|
||||
}
|
||||
}
|
||||
38
tests/e2e/scenarios/stage-simple-mode.yml
Normal file
38
tests/e2e/scenarios/stage-simple-mode.yml
Normal 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 видит страницу «Недоступно»
|
||||
204
tests/food-market.IntegrationTests/AdvancedModeTests.cs
Normal file
204
tests/food-market.IntegrationTests/AdvancedModeTests.cs
Normal 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 переключать нельзя");
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
169
tests/food-market.UnitTests/AdvancedModeFilterTests.cs
Normal file
169
tests/food-market.UnitTests/AdvancedModeFilterTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue