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] 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` (не гейтится — только мутации). Патч смока **не требуется**.
|
||||||
|
|
|
||||||
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")
|
.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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
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