test(s30): integration 3/3 + e2e stage-quick-login scenario (шаг 8/9)
Integration (QuickLoginTests.cs):
- Options_effective_matrix: Enabled=false → off; пустой/мусорный Issuer → off;
Issuer=admin.food-market.kz → off (прод); любой другой host (включая
test.admin.*) → on.
- Disabled_returns_404_and_rejects_master_password: default-factory
(Enabled=false) /accounts → 404, master-пароль → 400 invalid_grant,
обычный Admin12345! → 200 (sanity check).
- Enabled_at_runtime_...: мутируем IOptions<QuickLoginOptions>.Value +
IConfiguration в живом сервис-провайдере (так как WithWebHostBuilder на
уже-построенной parent-factory даёт ObjectDisposedException). Проверяем:
/accounts → 200 + SuperAdmin в списке; master-пароль пускает активного
SuperAdmin'а; НЕ пускает деактивированного; смена Issuer на прод
переключает всё в safety-off.
Также фикс в QuickLoginOptions: прод-хост теперь СТРОГО 'admin.food-market.kz'
без suffix-match (раньше test.admin.food-market.kz тоже считался прод-ом).
E2E (tests/e2e/scenarios/stage-quick-login.{yml,steps.ts}): 4 шага —
GET /accounts на стенде → 200 + демо-trio; master-пароль пускает
demo-cashier → /api/me role=Cashier; GET на прод → 404; Playwright: клик
по карточке demo-cashier → редирект с /login + нет ссылки /products
в sidebar (меню кассира).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c69114f8ab
commit
338877eb49
|
|
@ -39,7 +39,10 @@
|
||||||
`HousekeepingJobs.PruneQualityTestOrgsAsync` на новые autotest-патéрны,
|
`HousekeepingJobs.PruneQualityTestOrgsAsync` на новые autotest-патéрны,
|
||||||
чтобы cron 02:30 UTC гасил новые юзеры сам. Не трогать: SuperAdmin,
|
чтобы cron 02:30 UTC гасил новые юзеры сам. Не трогать: SuperAdmin,
|
||||||
admin@demo-market.local, market@email.com, demo-* аккаунты.
|
admin@demo-market.local, market@email.com, demo-* аккаунты.
|
||||||
- [ ] 8. **Тесты** — integration (3): Enabled=false → эндпоинт 404 + мастер-
|
- [x] 8. **Тесты** — integration 3/3 green (QuickLoginTests: options matrix,
|
||||||
|
disabled→404+rejects, enabled→master pass для активного/отшивает неактивного +
|
||||||
|
prod issuer выключает). E2E scenario `stage-quick-login` (4 шага) ждёт
|
||||||
|
deploy'а на стенд. Прод 404-проверка в ql03. integration (3): Enabled=false → эндпоинт 404 + мастер-
|
||||||
пароль отвергается; Issuer=admin.food-market.kz → выключен даже с
|
пароль отвергается; Issuer=admin.food-market.kz → выключен даже с
|
||||||
Enabled=true; Enabled=true → мастер-пароль пускает активного и не пускает
|
Enabled=true; Enabled=true → мастер-пароль пускает активного и не пускает
|
||||||
неактивного. E2E stage: клик по демо-кассиру → меню кассира. Прод: `curl
|
неактивного. E2E stage: клик по демо-кассиру → меню кассира. Прод: `curl
|
||||||
|
|
|
||||||
218
tests/e2e/scenarios/stage-quick-login.steps.ts
Normal file
218
tests/e2e/scenarios/stage-quick-login.steps.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
/**
|
||||||
|
* Stage quick-login: эндпоинт /api/dev/quick-login/accounts должен быть
|
||||||
|
* доступен на test.admin, выдавать SuperAdmin + demo-trio; master-пароль
|
||||||
|
* `123456` пускает demo-cashier'а; на admin.food-market.kz тот же эндпоинт
|
||||||
|
* должен давать 404.
|
||||||
|
*
|
||||||
|
* UI-шаг (ql04): открываем /login, ждём QuickLoginBlock, кликаем по
|
||||||
|
* карточке demo-cashier, ждём редиректа, проверяем что в sidebar НЕ
|
||||||
|
* видны пункты «Товары»/«Приёмки» (это админ-меню), только то, что
|
||||||
|
* положено кассиру.
|
||||||
|
*/
|
||||||
|
import { chromium, type Browser } from 'playwright'
|
||||||
|
import { makeClient, ADMIN_BASE } from '../lib/api.js'
|
||||||
|
import type { CheckResult, Step, Report } from '../lib/report.js'
|
||||||
|
|
||||||
|
type Ctx = {
|
||||||
|
apiOnly: boolean
|
||||||
|
accounts?: Array<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
interface StepCtx { ctx: Ctx; step: Step; report: Report }
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function ql01_stage_endpoint_returns_200({ ctx, step, report }: StepCtx) {
|
||||||
|
const api = makeClient()
|
||||||
|
const res = await api.get('/api/dev/quick-login/accounts')
|
||||||
|
const ok200 = res.status === 200
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'GET /api/dev/quick-login/accounts → 200',
|
||||||
|
ok: ok200,
|
||||||
|
detail: `status=${res.status}`,
|
||||||
|
})
|
||||||
|
if (!ok200) {
|
||||||
|
report.bug({
|
||||||
|
step: 'ql01',
|
||||||
|
severity: 'critical',
|
||||||
|
title:`/api/dev/quick-login/accounts вернул ${res.status} вместо 200`,
|
||||||
|
detail: `${ADMIN_BASE} должен работать как стенд. Проверь QuickLogin:Enabled=true в env.`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const accounts = Array.isArray(res.data) ? res.data : []
|
||||||
|
ctx.accounts = accounts
|
||||||
|
const hasSuperAdmin = accounts.some((a) => a.email === 'admin@food-market.local' && a.isSuperAdmin === true)
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'SuperAdmin в списке (admin@food-market.local)',
|
||||||
|
ok: hasSuperAdmin,
|
||||||
|
detail: `найдено ${accounts.length} аккаунтов`,
|
||||||
|
})
|
||||||
|
const demoEmails = [
|
||||||
|
'demo-admin@food-market.local',
|
||||||
|
'demo-cashier@food-market.local',
|
||||||
|
'demo-keeper@food-market.local',
|
||||||
|
]
|
||||||
|
for (const em of demoEmails) {
|
||||||
|
const found = accounts.find((a) => a.email === em)
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: `Демо-аккаунт ${em} в списке`,
|
||||||
|
ok: !!found && found.isDemo === true,
|
||||||
|
detail: found ? `role=${found.role} org=${found.organizationName}` : 'НЕТ',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function ql02_master_password_logs_in_demo_cashier({ step, report }: StepCtx) {
|
||||||
|
const api = makeClient()
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: 'password',
|
||||||
|
username: 'demo-cashier@food-market.local',
|
||||||
|
password: '123456',
|
||||||
|
client_id: 'food-market-web',
|
||||||
|
scope: 'openid profile email roles api offline_access',
|
||||||
|
})
|
||||||
|
const res = await api.post('/connect/token', body, {
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
})
|
||||||
|
const ok = res.status === 200 && typeof res.data?.access_token === 'string'
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'master-пароль "123456" пускает demo-cashier → 200 + access_token',
|
||||||
|
ok,
|
||||||
|
detail: ok ? 'token получен' : `status=${res.status} data=${asString(res.data).slice(0, 200)}`,
|
||||||
|
})
|
||||||
|
if (!ok) {
|
||||||
|
report.bug({
|
||||||
|
step: 'ql02',
|
||||||
|
severity: 'critical',
|
||||||
|
title:'master-пароль не работает для demo-cashier',
|
||||||
|
detail: 'QuickLogin:MasterPassword на стенде должен быть "123456", и demo-cashier активен.',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Проверяем роли через /api/me
|
||||||
|
const me = await makeClient(res.data.access_token).get('/api/me')
|
||||||
|
const roles: string[] = Array.isArray(me.data?.roles) ? me.data.roles : []
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: '/api/me → role=Cashier',
|
||||||
|
ok: roles.includes('Cashier'),
|
||||||
|
detail: `roles=${roles.join(',')}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function ql03_prod_endpoint_returns_404({ step, report }: StepCtx) {
|
||||||
|
// Read-only проверка на проде. Используем axios через base-url proxy —
|
||||||
|
// но ADMIN_BASE уже может быть test.admin. Берём прод-URL явно.
|
||||||
|
const api = makeClient()
|
||||||
|
const res = await api.get('https://admin.food-market.kz/api/dev/quick-login/accounts', {
|
||||||
|
// Абсолютный URL в axios перезаписывает baseURL.
|
||||||
|
})
|
||||||
|
const ok = res.status === 404
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'Прод admin.food-market.kz /api/dev/quick-login/accounts → 404',
|
||||||
|
ok,
|
||||||
|
detail: `status=${res.status}`,
|
||||||
|
})
|
||||||
|
if (!ok) {
|
||||||
|
report.bug({
|
||||||
|
step: 'ql03',
|
||||||
|
severity: 'critical',
|
||||||
|
title:`ПРОД отдал ${res.status} на quick-login endpoint — это дыра!`,
|
||||||
|
detail: 'На admin.food-market.kz должен быть 404. Если 200 — срочно откатить и проверить env.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function ql04_ui_click_demo_cashier_logs_in({ ctx, step, report }: StepCtx) {
|
||||||
|
if (ctx.apiOnly) {
|
||||||
|
check(step, {
|
||||||
|
kind: 'ui',
|
||||||
|
description: 'UI-шаг пропущен (--api-only)',
|
||||||
|
ok: true,
|
||||||
|
detail: 'skip',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let browser: Browser | null = null
|
||||||
|
try {
|
||||||
|
browser = await chromium.launch({ headless: true })
|
||||||
|
const context = await browser.newContext({ ignoreHTTPSErrors: true })
|
||||||
|
const page = await context.newPage()
|
||||||
|
|
||||||
|
await page.goto(`${ADMIN_BASE}/login`, { waitUntil: 'domcontentloaded', timeout: 30_000 })
|
||||||
|
// Ждём рендера блока «Быстрый вход»
|
||||||
|
const block = page.locator('[data-testid="quick-login-block"]')
|
||||||
|
await block.waitFor({ state: 'visible', timeout: 15_000 })
|
||||||
|
check(step, {
|
||||||
|
kind: 'ui',
|
||||||
|
description: 'QuickLoginBlock отрендерился на /login',
|
||||||
|
ok: true,
|
||||||
|
detail: 'data-testid=quick-login-block visible',
|
||||||
|
})
|
||||||
|
|
||||||
|
const cashierCard = page.locator('[data-testid="quick-login-pick-demo-cashier@food-market.local"]')
|
||||||
|
const visible = await cashierCard.isVisible().catch(() => false)
|
||||||
|
check(step, {
|
||||||
|
kind: 'ui',
|
||||||
|
description: 'Карточка demo-cashier видна в блоке',
|
||||||
|
ok: visible,
|
||||||
|
detail: visible ? 'OK' : 'не нашли селектор карточки',
|
||||||
|
})
|
||||||
|
if (!visible) return
|
||||||
|
|
||||||
|
await cashierCard.click()
|
||||||
|
// Ждём ухода с /login
|
||||||
|
await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 15_000 })
|
||||||
|
const landed = new URL(page.url()).pathname
|
||||||
|
check(step, {
|
||||||
|
kind: 'ui',
|
||||||
|
description: `После клика по демо-кассиру ушли с /login → ${landed}`,
|
||||||
|
ok: !landed.startsWith('/login'),
|
||||||
|
detail: landed,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Кассиру НЕ должно быть доступно меню «Товары» админа (роль Cashier).
|
||||||
|
// Если в sidebar есть ссылка на /products — это не меню кассира.
|
||||||
|
const productsLink = await page.locator('a[href="/products"], a[href^="/products?"]').count()
|
||||||
|
check(step, {
|
||||||
|
kind: 'ui',
|
||||||
|
description: 'В sidebar нет ссылки на /products (кассир её не видит)',
|
||||||
|
ok: productsLink === 0,
|
||||||
|
detail: `найдено ссылок: ${productsLink}`,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
const err = e instanceof Error ? e.message : String(e)
|
||||||
|
check(step, {
|
||||||
|
kind: 'ui',
|
||||||
|
description: 'UI-шаг упал с исключением',
|
||||||
|
ok: false,
|
||||||
|
detail: err.slice(0, 400),
|
||||||
|
})
|
||||||
|
report.bug({
|
||||||
|
step: 'ql04',
|
||||||
|
severity: 'high',
|
||||||
|
title:'UI-шаг quick-login упал',
|
||||||
|
detail: err,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
if (browser) await browser.close().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
25
tests/e2e/scenarios/stage-quick-login.yml
Normal file
25
tests/e2e/scenarios/stage-quick-login.yml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
name: stage-quick-login
|
||||||
|
description: |
|
||||||
|
Проверяет «быстрый вход» на тестовом стенде test.admin.food-market.kz:
|
||||||
|
эндпоинт /api/dev/quick-login/accounts должен быть доступен (стенд),
|
||||||
|
возвращать SuperAdmin + demo-trio (admin/cashier/keeper), а на проде
|
||||||
|
(admin.food-market.kz) тот же путь должен давать 404 — read-only
|
||||||
|
проверка, чтобы случайно не включили на prod.
|
||||||
|
|
||||||
|
UI-шаг (Playwright): клик по карточке демо-кассира → редирект после
|
||||||
|
логина → в sidebar видно, что открылось меню кассира (ограниченный
|
||||||
|
набор пунктов, без «Товары» админа).
|
||||||
|
|
||||||
|
preconditions:
|
||||||
|
reset_db: false
|
||||||
|
smoke_login_super_admin: false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: ql01_stage_endpoint_returns_200
|
||||||
|
title: GET /api/dev/quick-login/accounts (stage) → 200 + SuperAdmin + demo-trio в списке
|
||||||
|
- id: ql02_master_password_logs_in_demo_cashier
|
||||||
|
title: POST /connect/token с username=demo-cashier@food-market.local и password=123456 → 200, роль=Cashier
|
||||||
|
- id: ql03_prod_endpoint_returns_404
|
||||||
|
title: GET https://admin.food-market.kz/api/dev/quick-login/accounts → 404 (safety-override)
|
||||||
|
- id: ql04_ui_click_demo_cashier_logs_in
|
||||||
|
title: UI — клик по карточке demo-cashier на /login → редирект на /dashboard или /pos, видно меню кассира
|
||||||
186
tests/food-market.IntegrationTests/QuickLoginTests.cs
Normal file
186
tests/food-market.IntegrationTests/QuickLoginTests.cs
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using foodmarket.Api.Infrastructure;
|
||||||
|
using foodmarket.Infrastructure.Identity;
|
||||||
|
using foodmarket.IntegrationTests.Support;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace foodmarket.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>Sprint 30 — тесты «быстрого входа» на стенде.
|
||||||
|
///
|
||||||
|
/// Проверки:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>unit-матрица <see cref="QuickLoginOptions.EffectivelyEnabled"/>:
|
||||||
|
/// false/пустой Issuer/прод-хост → off; стенд → on;</item>
|
||||||
|
/// <item>default ApiFactory (Enabled=false): /accounts → 404 и мастер-
|
||||||
|
/// пароль отвергается;</item>
|
||||||
|
/// <item>включаем Enabled в runtime (мутируем <c>IOptions.Value</c>) +
|
||||||
|
/// подмешиваем stage-Issuer в <c>IConfiguration</c>: мастер-пароль
|
||||||
|
/// пускает активного и не пускает деактивированного; /accounts → 200.
|
||||||
|
/// Возвращаем состояние обратно в finally.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para><b>Про runtime-мутацию.</b> ASP.NET <c>WebApplicationFactory.
|
||||||
|
/// WithWebHostBuilder</c> на уже-построенной parent-factory даёт
|
||||||
|
/// <c>ObjectDisposedException</c> — переконфигурировать «на лету» нельзя.
|
||||||
|
/// Поэтому мы меняем <c>QuickLoginOptions</c> через <c>IOptions<T>.
|
||||||
|
/// Value</c> (это sync-ссылка на singleton-экземпляр — изменения видны
|
||||||
|
/// всем потребителям, включая controller). <c>OpenIddict:Issuer</c>
|
||||||
|
/// IConfiguration — провайдер (<c>MemoryConfigurationProvider</c>)
|
||||||
|
/// позволяет менять значение после Build через <c>Reload</c>.</para></summary>
|
||||||
|
[Collection(ApiCollection.Name)]
|
||||||
|
public class QuickLoginTests
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory;
|
||||||
|
public QuickLoginTests(ApiFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
private const string Master = "123456";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Options_effective_matrix()
|
||||||
|
{
|
||||||
|
var disabled = new QuickLoginOptions { Enabled = false };
|
||||||
|
disabled.EffectivelyEnabled("https://test.admin.food-market.kz/", out _).Should().BeFalse();
|
||||||
|
|
||||||
|
var noIssuer = new QuickLoginOptions { Enabled = true };
|
||||||
|
noIssuer.EffectivelyEnabled(null, out _).Should().BeFalse();
|
||||||
|
noIssuer.EffectivelyEnabled("", out _).Should().BeFalse();
|
||||||
|
|
||||||
|
var prodIssuer = new QuickLoginOptions { Enabled = true };
|
||||||
|
prodIssuer.EffectivelyEnabled("https://admin.food-market.kz/", out _).Should().BeFalse();
|
||||||
|
// Любой поддомен — это стенд
|
||||||
|
prodIssuer.EffectivelyEnabled("https://test.admin.food-market.kz/", out _).Should().BeTrue();
|
||||||
|
|
||||||
|
// Non-URL в конфиге — тоже off (не рискуем).
|
||||||
|
new QuickLoginOptions { Enabled = true }
|
||||||
|
.EffectivelyEnabled("это-не-url", out _).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Disabled_returns_404_and_rejects_master_password()
|
||||||
|
{
|
||||||
|
var http = _factory.CreateClient();
|
||||||
|
|
||||||
|
var get = await http.GetAsync("/api/dev/quick-login/accounts");
|
||||||
|
get.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||||
|
|
||||||
|
var token = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "password",
|
||||||
|
["username"] = "admin@food-market.local",
|
||||||
|
["password"] = Master,
|
||||||
|
["client_id"] = "food-market-web",
|
||||||
|
["scope"] = "openid profile email roles api offline_access",
|
||||||
|
}));
|
||||||
|
token.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||||
|
var body = await token.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
body.GetProperty("error").GetString().Should().Be("invalid_grant");
|
||||||
|
|
||||||
|
// Правильный пароль у admin'а — sanity-check, что backend вообще отвечает OK.
|
||||||
|
var ok = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "password",
|
||||||
|
["username"] = "admin@food-market.local",
|
||||||
|
["password"] = "Admin12345!",
|
||||||
|
["client_id"] = "food-market-web",
|
||||||
|
["scope"] = "openid profile email roles api",
|
||||||
|
}));
|
||||||
|
ok.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Enabled_at_runtime_allows_master_for_active_and_rejects_inactive_and_lists_accounts()
|
||||||
|
{
|
||||||
|
// Мутируем singleton-опции в самом живом сервис-провайдере. Делаем
|
||||||
|
// внутри try/finally чтобы другие тесты коллекции не унаследовали
|
||||||
|
// Enabled=true состояние.
|
||||||
|
var opts = _factory.Services.GetRequiredService<IOptions<QuickLoginOptions>>().Value;
|
||||||
|
var cfg = _factory.Services.GetRequiredService<IConfiguration>();
|
||||||
|
|
||||||
|
var prevEnabled = opts.Enabled;
|
||||||
|
var prevPwd = opts.MasterPassword;
|
||||||
|
var prevIssuer = cfg["OpenIddict:Issuer"];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
opts.Enabled = true;
|
||||||
|
opts.MasterPassword = Master;
|
||||||
|
cfg["OpenIddict:Issuer"] = "https://test.admin.food-market.kz/";
|
||||||
|
|
||||||
|
var http = _factory.CreateClient();
|
||||||
|
|
||||||
|
// /accounts → 200 + SuperAdmin в списке.
|
||||||
|
var list = await http.GetAsync("/api/dev/quick-login/accounts");
|
||||||
|
list.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var items = await list.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
items.ValueKind.Should().Be(JsonValueKind.Array);
|
||||||
|
items.EnumerateArray()
|
||||||
|
.Any(x => x.GetProperty("email").GetString() == "admin@food-market.local"
|
||||||
|
&& x.GetProperty("isSuperAdmin").GetBoolean())
|
||||||
|
.Should().BeTrue();
|
||||||
|
|
||||||
|
// Master-пароль пускает активного SuperAdmin'а.
|
||||||
|
var tokenOk = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "password",
|
||||||
|
["username"] = "admin@food-market.local",
|
||||||
|
["password"] = Master,
|
||||||
|
["client_id"] = "food-market-web",
|
||||||
|
["scope"] = "openid profile email roles api",
|
||||||
|
}));
|
||||||
|
tokenOk.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
// Создаём неактивного и проверяем, что master-пароль его НЕ пускает.
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var mgr = scope.ServiceProvider.GetRequiredService<UserManager<User>>();
|
||||||
|
var email = $"ql-inactive-{Guid.NewGuid():N}@food-market.local";
|
||||||
|
var inactive = new User
|
||||||
|
{
|
||||||
|
UserName = email,
|
||||||
|
Email = email,
|
||||||
|
EmailConfirmed = true,
|
||||||
|
FullName = "Inactive",
|
||||||
|
IsActive = false,
|
||||||
|
OrganizationId = null,
|
||||||
|
};
|
||||||
|
(await mgr.CreateAsync(inactive, "Passw0rd!")).Succeeded.Should().BeTrue();
|
||||||
|
|
||||||
|
var reject = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "password",
|
||||||
|
["username"] = email,
|
||||||
|
["password"] = Master,
|
||||||
|
["client_id"] = "food-market-web",
|
||||||
|
["scope"] = "openid profile email roles api",
|
||||||
|
}));
|
||||||
|
reject.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||||
|
|
||||||
|
// Прод-хост в Issuer — safety-override должен выключить всё, даже
|
||||||
|
// при Enabled=true.
|
||||||
|
cfg["OpenIddict:Issuer"] = "https://admin.food-market.kz/";
|
||||||
|
var prodList = await http.GetAsync("/api/dev/quick-login/accounts");
|
||||||
|
prodList.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||||
|
var prodToken = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "password",
|
||||||
|
["username"] = "admin@food-market.local",
|
||||||
|
["password"] = Master,
|
||||||
|
["client_id"] = "food-market-web",
|
||||||
|
["scope"] = "openid profile email roles api",
|
||||||
|
}));
|
||||||
|
prodToken.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
opts.Enabled = prevEnabled;
|
||||||
|
opts.MasterPassword = prevPwd;
|
||||||
|
cfg["OpenIddict:Issuer"] = prevIssuer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue