diff --git a/docs/quick-login-progress.md b/docs/quick-login-progress.md index a1785b3..2ddf8fd 100644 --- a/docs/quick-login-progress.md +++ b/docs/quick-login-progress.md @@ -39,7 +39,10 @@ `HousekeepingJobs.PruneQualityTestOrgsAsync` на новые autotest-патéрны, чтобы cron 02:30 UTC гасил новые юзеры сам. Не трогать: SuperAdmin, 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 → выключен даже с Enabled=true; Enabled=true → мастер-пароль пускает активного и не пускает неактивного. E2E stage: клик по демо-кассиру → меню кассира. Прод: `curl diff --git a/tests/e2e/scenarios/stage-quick-login.steps.ts b/tests/e2e/scenarios/stage-quick-login.steps.ts new file mode 100644 index 0000000..a3b85c4 --- /dev/null +++ b/tests/e2e/scenarios/stage-quick-login.steps.ts @@ -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> +} +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(() => {}) + } +} diff --git a/tests/e2e/scenarios/stage-quick-login.yml b/tests/e2e/scenarios/stage-quick-login.yml new file mode 100644 index 0000000..6fb2087 --- /dev/null +++ b/tests/e2e/scenarios/stage-quick-login.yml @@ -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, видно меню кассира diff --git a/tests/food-market.IntegrationTests/QuickLoginTests.cs b/tests/food-market.IntegrationTests/QuickLoginTests.cs new file mode 100644 index 0000000..7d68c00 --- /dev/null +++ b/tests/food-market.IntegrationTests/QuickLoginTests.cs @@ -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; + +/// Sprint 30 — тесты «быстрого входа» на стенде. +/// +/// Проверки: +/// +/// unit-матрица : +/// false/пустой Issuer/прод-хост → off; стенд → on; +/// default ApiFactory (Enabled=false): /accounts → 404 и мастер- +/// пароль отвергается; +/// включаем Enabled в runtime (мутируем IOptions.Value) + +/// подмешиваем stage-Issuer в IConfiguration: мастер-пароль +/// пускает активного и не пускает деактивированного; /accounts → 200. +/// Возвращаем состояние обратно в finally. +/// +/// +/// Про runtime-мутацию. ASP.NET WebApplicationFactory. +/// WithWebHostBuilder на уже-построенной parent-factory даёт +/// ObjectDisposedException — переконфигурировать «на лету» нельзя. +/// Поэтому мы меняем QuickLoginOptions через IOptions<T>. +/// Value (это sync-ссылка на singleton-экземпляр — изменения видны +/// всем потребителям, включая controller). OpenIddict:Issuer +/// IConfiguration — провайдер (MemoryConfigurationProvider) +/// позволяет менять значение после Build через Reload. +[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 + { + ["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(); + body.GetProperty("error").GetString().Should().Be("invalid_grant"); + + // Правильный пароль у admin'а — sanity-check, что backend вообще отвечает OK. + var ok = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary + { + ["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>().Value; + var cfg = _factory.Services.GetRequiredService(); + + 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(); + 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 + { + ["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>(); + 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 + { + ["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 + { + ["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; + } + } +}