Compare commits
12 commits
8572875e7f
...
eccdd06a4c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eccdd06a4c | ||
|
|
f817abc8c7 | ||
|
|
e3a8db3206 | ||
|
|
ee24a5ed8e | ||
|
|
338877eb49 | ||
|
|
c69114f8ab | ||
|
|
5728b729a8 | ||
|
|
6582333def | ||
|
|
35f633e206 | ||
|
|
ed5089bac3 | ||
|
|
7647af7527 | ||
|
|
1f70d90485 |
|
|
@ -39,6 +39,13 @@ services:
|
||||||
# e2e/smoke; в prod'е оставляем дефолты 3/час, 10/сутки.
|
# e2e/smoke; в prod'е оставляем дефолты 3/час, 10/сутки.
|
||||||
RateLimiting__SignupPerIpPerHour: ${RATE_SIGNUP_HOUR:-3}
|
RateLimiting__SignupPerIpPerHour: ${RATE_SIGNUP_HOUR:-3}
|
||||||
RateLimiting__SignupPerIpPerDay: ${RATE_SIGNUP_DAY:-10}
|
RateLimiting__SignupPerIpPerDay: ${RATE_SIGNUP_DAY:-10}
|
||||||
|
# Sprint 30: «быстрый вход» на тестовом стенде. По умолчанию выключено.
|
||||||
|
# На stage включается через QUICK_LOGIN_ENABLED=true в .env. Code
|
||||||
|
# имеет safety-override: даже с Enabled=true, если OpenIddict__Issuer
|
||||||
|
# смотрит на admin.food-market.kz — быстрый вход принудительно выключен
|
||||||
|
# (см. QuickLoginOptions.EffectivelyEnabled).
|
||||||
|
QuickLogin__Enabled: ${QUICK_LOGIN_ENABLED:-false}
|
||||||
|
QuickLogin__MasterPassword: ${QUICK_LOGIN_MASTER_PASSWORD:-123456}
|
||||||
# Host port mapping: pick free ports on existing stage server (80/443 taken by
|
# Host port mapping: pick free ports on existing stage server (80/443 taken by
|
||||||
# legacy nginx, 5000/5002/5005 taken by legacy .NET apps).
|
# legacy nginx, 5000/5002/5005 taken by legacy .NET apps).
|
||||||
ports:
|
ports:
|
||||||
|
|
|
||||||
174
deploy/stage-prune-autotest.sql
Normal file
174
deploy/stage-prune-autotest.sql
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
-- deploy/stage-prune-autotest.sql
|
||||||
|
-- Одноразовая зачистка стейджа от автотестового мусора. Пишется как idempotent
|
||||||
|
-- SQL-скрипт — его же логика живёт в HousekeepingJobs.PruneAutoTestEntitiesAsync
|
||||||
|
-- (Hangfire 02:45 UTC daily). Этот файл пригоден для ручного запуска
|
||||||
|
-- (например, на prod-vm через `docker exec food-market-stage-postgres-1 psql
|
||||||
|
-- -U food_market -d food_market -f /tmp/stage-prune-autotest.sql`).
|
||||||
|
--
|
||||||
|
-- Что НЕ трогает:
|
||||||
|
-- * admin@food-market.local (SuperAdmin — OrganizationId IS NULL)
|
||||||
|
-- * admin@demo-market.local
|
||||||
|
-- * market@email.com
|
||||||
|
-- * demo-*@food-market.local (quick-login demo-trio)
|
||||||
|
-- * «Demo Market» и «Демо-магазин» (seeded orgs)
|
||||||
|
--
|
||||||
|
-- Условие удаления: имя орги подходит под регулярку autotest ИЛИ email юзера
|
||||||
|
-- подходит под autotest-патéрн / служебный домен. Для свежих прогонов —
|
||||||
|
-- threshold 3 дня (меняется через psql -v).
|
||||||
|
|
||||||
|
\set threshold_days 3
|
||||||
|
|
||||||
|
DO $do$
|
||||||
|
DECLARE
|
||||||
|
threshold_ts timestamp with time zone := NOW() - INTERVAL '3 days';
|
||||||
|
org_ids uuid[];
|
||||||
|
tbl text;
|
||||||
|
pass int := 0;
|
||||||
|
remaining int;
|
||||||
|
autotest_org_rx text := '[0-9]{10,}' ||
|
||||||
|
'|^quality-|^soak-|^(ui-)?a11y-|^smokeorg[ab]?-|^perfbaseline-|^reg-' ||
|
||||||
|
'|^rt-ratetest-|^probe\s|^cat3[0-9]-|^visual-|^factory-smoke-|^mt6[0-9]-' ||
|
||||||
|
'|^doc4[0-9]-|^auth2[0-9]-|^rep5[0-9]-|^rt8[0-9]-|^wiz9[0-9]-' ||
|
||||||
|
'|^i18n[0-9]+-|^twofa[0-9]+-|^iso[a-z]*[0-9]*-|^mob1[0-9]-|^axdet-' ||
|
||||||
|
'|^load-sales-|^load-|^s19-test-';
|
||||||
|
autotest_user_rx text := '^(cat3[0-9]|visual|mt6[0-9]|factory|doc4[0-9]|auth2[0-9]|rep5[0-9]|rt8[0-9]|wiz9[0-9]|quality|i18n[0-9]+|twofa[0-9]+|a11y|mob1[0-9]|iso|axdet|smoke|ratetest|perf|probe|load|s19-test|admin-[0-9]|cashier-[0-9]|keeper-[0-9]|reg-mt)';
|
||||||
|
autotest_domain_rx text := '(@test-fm\.local|@e\.kz)$';
|
||||||
|
deleted_orgs int;
|
||||||
|
deleted_users int;
|
||||||
|
BEGIN
|
||||||
|
SELECT array_agg("Id") INTO org_ids FROM public.organizations
|
||||||
|
WHERE "Name" <> 'Demo Market'
|
||||||
|
AND "Name" <> 'Демо-магазин'
|
||||||
|
AND "Name" ~* autotest_org_rx
|
||||||
|
AND "CreatedAt" < threshold_ts;
|
||||||
|
|
||||||
|
IF array_length(org_ids, 1) IS NOT NULL THEN
|
||||||
|
RAISE NOTICE 'Нашли % autotest-органов для удаления', array_length(org_ids, 1);
|
||||||
|
|
||||||
|
FOR pass IN 1..10 LOOP
|
||||||
|
remaining := 0;
|
||||||
|
FOR tbl IN
|
||||||
|
SELECT c.table_name
|
||||||
|
FROM information_schema.columns c
|
||||||
|
JOIN information_schema.tables t
|
||||||
|
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
|
||||||
|
WHERE c.table_schema = 'public'
|
||||||
|
AND c.column_name = 'OrganizationId'
|
||||||
|
AND t.table_type = 'BASE TABLE'
|
||||||
|
AND c.table_name NOT IN ('organizations', 'users')
|
||||||
|
LOOP
|
||||||
|
BEGIN
|
||||||
|
EXECUTE format('DELETE FROM public.%I WHERE "OrganizationId" = ANY($1)', tbl) USING org_ids;
|
||||||
|
EXCEPTION WHEN foreign_key_violation THEN
|
||||||
|
remaining := remaining + 1;
|
||||||
|
END;
|
||||||
|
END LOOP;
|
||||||
|
EXIT WHEN remaining = 0;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
DELETE FROM "AspNetUserRoles" WHERE "UserId" IN (
|
||||||
|
SELECT u."Id" FROM public.users u WHERE u."OrganizationId" = ANY(org_ids)
|
||||||
|
);
|
||||||
|
DELETE FROM "AspNetUserTokens" WHERE "UserId" IN (
|
||||||
|
SELECT u."Id" FROM public.users u WHERE u."OrganizationId" = ANY(org_ids)
|
||||||
|
);
|
||||||
|
DELETE FROM "AspNetUserClaims" WHERE "UserId" IN (
|
||||||
|
SELECT u."Id" FROM public.users u WHERE u."OrganizationId" = ANY(org_ids)
|
||||||
|
);
|
||||||
|
DELETE FROM "AspNetUserLogins" WHERE "UserId" IN (
|
||||||
|
SELECT u."Id" FROM public.users u WHERE u."OrganizationId" = ANY(org_ids)
|
||||||
|
);
|
||||||
|
DELETE FROM "OpenIddictTokens" WHERE "Subject" IN (
|
||||||
|
SELECT u."Id"::text FROM public.users u WHERE u."OrganizationId" = ANY(org_ids)
|
||||||
|
);
|
||||||
|
DELETE FROM "OpenIddictAuthorizations" WHERE "Subject" IN (
|
||||||
|
SELECT u."Id"::text FROM public.users u WHERE u."OrganizationId" = ANY(org_ids)
|
||||||
|
);
|
||||||
|
|
||||||
|
GET DIAGNOSTICS deleted_users = ROW_COUNT;
|
||||||
|
DELETE FROM public.users WHERE "OrganizationId" = ANY(org_ids);
|
||||||
|
DELETE FROM public.organizations WHERE "Id" = ANY(org_ids);
|
||||||
|
GET DIAGNOSTICS deleted_orgs = ROW_COUNT;
|
||||||
|
RAISE NOTICE 'Удалено orgs=%, users с org=%', deleted_orgs, deleted_users;
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Нет autotest-органов старше %', threshold_ts;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Orphan autotest-юзеры (без tagged org). Исключения — такие же как в
|
||||||
|
-- Hangfire-методе: SuperAdmin, demo-trio, admin@demo-market.local, market@.
|
||||||
|
DELETE FROM "AspNetUserRoles" WHERE "UserId" IN (
|
||||||
|
SELECT u."Id" FROM public.users u
|
||||||
|
WHERE (u."Email" ~* autotest_user_rx OR u."Email" ~* autotest_domain_rx OR u."Email" ~ '[0-9]{10,}')
|
||||||
|
AND u."Email" NOT LIKE 'demo-%@food-market.local'
|
||||||
|
AND u."Email" <> 'admin@food-market.local'
|
||||||
|
AND u."Email" <> 'admin@demo-market.local'
|
||||||
|
AND u."Email" <> 'market@email.com'
|
||||||
|
AND u."OrganizationId" IS NOT NULL
|
||||||
|
AND u."CreatedAt" < threshold_ts
|
||||||
|
);
|
||||||
|
DELETE FROM "AspNetUserTokens" WHERE "UserId" IN (
|
||||||
|
SELECT u."Id" FROM public.users u
|
||||||
|
WHERE (u."Email" ~* autotest_user_rx OR u."Email" ~* autotest_domain_rx OR u."Email" ~ '[0-9]{10,}')
|
||||||
|
AND u."Email" NOT LIKE 'demo-%@food-market.local'
|
||||||
|
AND u."Email" <> 'admin@food-market.local'
|
||||||
|
AND u."Email" <> 'admin@demo-market.local'
|
||||||
|
AND u."Email" <> 'market@email.com'
|
||||||
|
AND u."OrganizationId" IS NOT NULL
|
||||||
|
AND u."CreatedAt" < threshold_ts
|
||||||
|
);
|
||||||
|
DELETE FROM "AspNetUserClaims" WHERE "UserId" IN (
|
||||||
|
SELECT u."Id" FROM public.users u
|
||||||
|
WHERE (u."Email" ~* autotest_user_rx OR u."Email" ~* autotest_domain_rx OR u."Email" ~ '[0-9]{10,}')
|
||||||
|
AND u."Email" NOT LIKE 'demo-%@food-market.local'
|
||||||
|
AND u."Email" <> 'admin@food-market.local'
|
||||||
|
AND u."Email" <> 'admin@demo-market.local'
|
||||||
|
AND u."Email" <> 'market@email.com'
|
||||||
|
AND u."OrganizationId" IS NOT NULL
|
||||||
|
AND u."CreatedAt" < threshold_ts
|
||||||
|
);
|
||||||
|
DELETE FROM "AspNetUserLogins" WHERE "UserId" IN (
|
||||||
|
SELECT u."Id" FROM public.users u
|
||||||
|
WHERE (u."Email" ~* autotest_user_rx OR u."Email" ~* autotest_domain_rx OR u."Email" ~ '[0-9]{10,}')
|
||||||
|
AND u."Email" NOT LIKE 'demo-%@food-market.local'
|
||||||
|
AND u."Email" <> 'admin@food-market.local'
|
||||||
|
AND u."Email" <> 'admin@demo-market.local'
|
||||||
|
AND u."Email" <> 'market@email.com'
|
||||||
|
AND u."OrganizationId" IS NOT NULL
|
||||||
|
AND u."CreatedAt" < threshold_ts
|
||||||
|
);
|
||||||
|
DELETE FROM "OpenIddictTokens" WHERE "Subject" IN (
|
||||||
|
SELECT u."Id"::text FROM public.users u
|
||||||
|
WHERE (u."Email" ~* autotest_user_rx OR u."Email" ~* autotest_domain_rx OR u."Email" ~ '[0-9]{10,}')
|
||||||
|
AND u."Email" NOT LIKE 'demo-%@food-market.local'
|
||||||
|
AND u."Email" <> 'admin@food-market.local'
|
||||||
|
AND u."Email" <> 'admin@demo-market.local'
|
||||||
|
AND u."Email" <> 'market@email.com'
|
||||||
|
AND u."OrganizationId" IS NOT NULL
|
||||||
|
AND u."CreatedAt" < threshold_ts
|
||||||
|
);
|
||||||
|
DELETE FROM "OpenIddictAuthorizations" WHERE "Subject" IN (
|
||||||
|
SELECT u."Id"::text FROM public.users u
|
||||||
|
WHERE (u."Email" ~* autotest_user_rx OR u."Email" ~* autotest_domain_rx OR u."Email" ~ '[0-9]{10,}')
|
||||||
|
AND u."Email" NOT LIKE 'demo-%@food-market.local'
|
||||||
|
AND u."Email" <> 'admin@food-market.local'
|
||||||
|
AND u."Email" <> 'admin@demo-market.local'
|
||||||
|
AND u."Email" <> 'market@email.com'
|
||||||
|
AND u."OrganizationId" IS NOT NULL
|
||||||
|
AND u."CreatedAt" < threshold_ts
|
||||||
|
);
|
||||||
|
DELETE FROM public.users u
|
||||||
|
WHERE (u."Email" ~* autotest_user_rx OR u."Email" ~* autotest_domain_rx OR u."Email" ~ '[0-9]{10,}')
|
||||||
|
AND u."Email" NOT LIKE 'demo-%@food-market.local'
|
||||||
|
AND u."Email" <> 'admin@food-market.local'
|
||||||
|
AND u."Email" <> 'admin@demo-market.local'
|
||||||
|
AND u."Email" <> 'market@email.com'
|
||||||
|
AND u."OrganizationId" IS NOT NULL
|
||||||
|
AND u."CreatedAt" < threshold_ts;
|
||||||
|
GET DIAGNOSTICS deleted_users = ROW_COUNT;
|
||||||
|
RAISE NOTICE 'Удалено orphan autotest-юзеров: %', deleted_users;
|
||||||
|
END $do$;
|
||||||
|
|
||||||
|
-- Итог
|
||||||
|
SELECT 'users_remaining' AS metric, COUNT(*) AS value FROM users
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'orgs_remaining', COUNT(*) FROM organizations;
|
||||||
146
docs/quick-login-progress.md
Normal file
146
docs/quick-login-progress.md
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
# Quick Login — прогресс
|
||||||
|
|
||||||
|
Задача владельца: на `test.admin.food-market.kz` не набирать логин/пароль руками.
|
||||||
|
Выбрать аккаунт из списка, пароль у всех один — `123456`. На `admin.food-market.kz`
|
||||||
|
этого нет никогда (несколько независимых предохранителей).
|
||||||
|
|
||||||
|
Координатор отдельно проверил: на стейдже нет реальных секретов (МойСклад
|
||||||
|
токенов нет, ОФД-ключей нет, Telegram не привязан, SMTP пустой) — потому
|
||||||
|
открытый мастер-пароль на стенде безопасен.
|
||||||
|
|
||||||
|
## Чек-лист — все 9/9 ✓
|
||||||
|
|
||||||
|
- [x] 1. **Флаг конфигурации** — `QuickLogin:Enabled` (default false) +
|
||||||
|
`QuickLogin:MasterPassword` (default `123456`). Включается только через
|
||||||
|
env-переменные `QuickLogin__Enabled=true` в stage-compose. Жёсткий
|
||||||
|
предохранитель: если `OpenIddict:Issuer` указывает на admin.food-market.kz —
|
||||||
|
быстрый вход принудительно выключен и в лог Warning.
|
||||||
|
Commit: `1f70d90`.
|
||||||
|
- [x] 2. **Мастер-пароль** — password-grant: `QuickLogin:MasterPassword`
|
||||||
|
работает как второй вход рядом со своим паролем. Неактивные/без-org
|
||||||
|
пользователи — отшиваются как раньше. Rate-limit не ослаблен (`UseAuthForm
|
||||||
|
UsernameKey` + `UseRateLimiter` работают ДО controller'а). Commit: `7647af7`.
|
||||||
|
- [x] 3. **API списка аккаунтов** — `GET /api/dev/quick-login/accounts`
|
||||||
|
(анонимный, 404 если выключен). Порядок: SuperAdmin → демо-* → остальные
|
||||||
|
«человеческие». Autotest-патéрны (cat31-*, visual-*, factory-smoke-*, mt6*,
|
||||||
|
quality-*, i18n*-*, auth2*-*, doc4*-*, rep5*-*, rt8*-*, twofa*-*, wiz9*-*,
|
||||||
|
a11y-*, mob1*-*, isoB*-*, axdet-*, smoke-*, ratetest-*, perf-*, probe-*,
|
||||||
|
load-*, s19-test-* и всё с суффиксом из ≥10 цифр) — скрыты. Лимит 50 + `?q=`.
|
||||||
|
Commit: `ed5089b`.
|
||||||
|
- [x] 4. **Демо-аккаунты** — идемпотентный сид «Демо-магазин» + сотрудники с
|
||||||
|
ролями Admin / Cashier / Storekeeper (`demo-admin@food-market.local`,
|
||||||
|
`demo-cashier@...`, `demo-keeper@...`). Запускается только при Enabled=true.
|
||||||
|
Catalog-step (DemoTenantSeeder) падает с «Tenant not set» в startup-режиме
|
||||||
|
без HTTP-контекста — изолирован в try/catch в отдельном scope, не блокирует
|
||||||
|
создание юзеров. Commits: `35f633e`, `e3a8db3`, `f817abc`.
|
||||||
|
- [x] 5. **Страница входа (web)** — `QuickLoginBlock` над формой логина.
|
||||||
|
Опрашивает `/api/dev/quick-login/accounts`: 200 → рендерит жёлтый блок с
|
||||||
|
карточками + поиск; 404 → возвращает null, UI не рисуется. Клик по карточке →
|
||||||
|
моментальный login через мастер-пароль. Один web-билд ходит и в прод и на
|
||||||
|
стенд. Commit: `6582333`.
|
||||||
|
- [x] 6. **Плашка «ТЕСТОВЫЙ СТЕНД»** — `<StageBanner />` вверху всех страниц
|
||||||
|
при 200 на `/accounts`. Решение кешируется в sessionStorage. Динамически
|
||||||
|
добавляет `<meta name="robots" content="noindex,nofollow">`. Commit: `5728b72`.
|
||||||
|
- [x] 7. **Чистка автотестового мусора** — `HousekeepingJobs.PruneAutoTest
|
||||||
|
EntitiesAsync` — Hangfire-recurring (02:45 UTC), удаляет autotest orgs/
|
||||||
|
users старше 3 дней. `deploy/stage-prune-autotest.sql` — одноразовый
|
||||||
|
SQL-скрипт с той же логикой. Прогнал на stage: было **8653 users / 8634 orgs
|
||||||
|
→ стало 300 users / 305 orgs**. Protected: SuperAdmin, admin@demo-market.local,
|
||||||
|
market@email.com, demo-* trio, «Demo Market», «Демо-магазин». Commit: `c69114f`.
|
||||||
|
- [x] 8. **Тесты** — **integration 3/3 green** (QuickLoginTests: options
|
||||||
|
matrix, disabled→404+rejects, enabled→master pass активному / отшивает
|
||||||
|
неактивного + prod issuer выключает). **E2E stage-quick-login 4/4 green**
|
||||||
|
(api 3 + UI 1 через Playwright): /accounts → 200 + demo-trio, master-пароль
|
||||||
|
пускает demo-cashier, прод endpoint НЕ отдаёт список, UI клик по карточке →
|
||||||
|
редирект с /login без ссылки на /products. Commit: `338877e`.
|
||||||
|
- [x] 9. **Деплой + документация** — stage compose + .env обновлены
|
||||||
|
(`QUICK_LOGIN_ENABLED=true`), `~/deploy-stage.sh` отработал, стенд живой,
|
||||||
|
endpoint отдаёт SuperAdmin + 3 демо + 1 Admin'а org'и `market1`.
|
||||||
|
`docs/quick-login.md` описывает как работает, предохранители, как выключить.
|
||||||
|
Commits: `ee24a5e` (fix прод-host), `e3a8db3`+`f817abc` (safety wrap +
|
||||||
|
scope-split).
|
||||||
|
|
||||||
|
## Финальная проверка — что живо
|
||||||
|
|
||||||
|
```
|
||||||
|
curl https://test.admin.food-market.kz/api/dev/quick-login/accounts
|
||||||
|
```
|
||||||
|
|
||||||
|
Отдаёт 5 аккаунтов:
|
||||||
|
```
|
||||||
|
admin@food-market.local role=SuperAdmin org=Платформа super=true
|
||||||
|
demo-admin@food-market.local role=Администратор org=Демо-магазин demo=true
|
||||||
|
demo-cashier@food-market.local role=Кассир org=Демо-магазин demo=true
|
||||||
|
demo-keeper@food-market.local role=Кладовщик org=Демо-магазин demo=true
|
||||||
|
market@email.com role=Администратор org=market1
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -X POST .../connect/token password=123456 username=demo-cashier@...
|
||||||
|
→ 200 + access_token; /api/me → role=Cashier
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
curl https://admin.food-market.kz/api/dev/quick-login/accounts
|
||||||
|
→ 502 (прод-backend остановлен ~2 мес.; главное — не 200, список не экспонируется)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Прод-outage.** `food-market-api` container на prod-vm — `Exited (0) 2 months
|
||||||
|
ago`. Это _пре-существующее состояние_, не связано с моим деплоем. Для
|
||||||
|
quick-login это даже более безопасно, чем 404 (нечего отдать), но admin.food-
|
||||||
|
market.kz в целом нуждается в восстановлении — это отдельная задача, не
|
||||||
|
блокирует закрытие S30. Отмечено в E2E-отчёте как `gap`.
|
||||||
|
|
||||||
|
## Что в коде
|
||||||
|
|
||||||
|
| Что | Где |
|
||||||
|
|---|---|
|
||||||
|
| Опции + safety-override | `src/food-market.api/Infrastructure/QuickLoginOptions.cs` |
|
||||||
|
| Master-password в token-grant | `src/food-market.api/Controllers/AuthorizationController.cs` |
|
||||||
|
| Список аккаунтов | `src/food-market.api/Controllers/QuickLoginController.cs` |
|
||||||
|
| Демо-сид | `src/food-market.api/Seed/QuickLoginDemoSeeder.cs` |
|
||||||
|
| Чистка autotests | `src/food-market.api/Background/HousekeepingJobs.cs` + `deploy/stage-prune-autotest.sql` |
|
||||||
|
| UI блок | `src/food-market.web/src/components/QuickLoginBlock.tsx` |
|
||||||
|
| Плашка стенда | `src/food-market.web/src/components/StageBanner.tsx` |
|
||||||
|
| Integration-тесты (3) | `tests/food-market.IntegrationTests/QuickLoginTests.cs` |
|
||||||
|
| E2E-сценарий (4 шага) | `tests/e2e/scenarios/stage-quick-login.{yml,steps.ts}` |
|
||||||
|
| Инструкция | `docs/quick-login.md` |
|
||||||
|
| Прод-compose шаблон | `deploy/docker-compose.yml` (env default off) |
|
||||||
|
|
||||||
|
## Коммиты в main (хронологически)
|
||||||
|
|
||||||
|
```
|
||||||
|
1f70d90 feat(s30): QuickLoginOptions с safety-override по Issuer (1/9)
|
||||||
|
7647af7 feat(s30): мастер-пароль в /connect/token при EffectivelyEnabled (2/9)
|
||||||
|
ed5089b feat(s30): /api/dev/quick-login/accounts — анонимный список (3/9)
|
||||||
|
35f633e feat(s30): QuickLoginDemoSeeder — организация + Admin/Cashier/Keeper (4/9)
|
||||||
|
6582333 feat(s30): QuickLoginBlock + интеграция в LoginPage (5/9)
|
||||||
|
5728b72 feat(s30): плашка ТЕСТОВЫЙ СТЕНД + robots noindex (6/9)
|
||||||
|
c69114f feat(s30): PruneAutoTestEntities + одноразовая зачистка стейджа (7/9)
|
||||||
|
338877e test(s30): integration 3/3 + e2e stage-quick-login scenario (8/9)
|
||||||
|
ee24a5e fix(s30): прод-хост в QuickLoginOptions — точное совпадение, не suffix (9/9)
|
||||||
|
e3a8db3 fix(s30): QuickLoginDemoSeeder больше не кладёт API при сбое
|
||||||
|
f817abc fix(s30): QuickLoginDemoSeeder — свежий scope на каждый sub-step
|
||||||
|
```
|
||||||
|
|
||||||
|
## Правила (сам себе) — все соблюдены
|
||||||
|
|
||||||
|
- Мастер-пароль и список аккаунтов — **никогда не на проде**. Три слоя защиты:
|
||||||
|
(а) `QuickLogin:Enabled=false` по умолчанию; (б) `EffectivelyEnabled`
|
||||||
|
принудительно выключает при `Issuer=admin.food-market.kz`; (в) пустой/
|
||||||
|
мусорный Issuer — выключен.
|
||||||
|
- После каждого пункта: build + тесты + коммит порцией + `[x]`. ✓
|
||||||
|
- Не трогал: прод admin.food-market.kz (только read-only), `global.json`,
|
||||||
|
POS WPF, gateway nginx. ✓
|
||||||
|
|
||||||
|
## Как выключить обратно
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# на prod-vm
|
||||||
|
sed -i '/^QUICK_LOGIN_ENABLED=/d; /^QUICK_LOGIN_MASTER_PASSWORD=/d' \
|
||||||
|
~/food-market-stage/deploy/.env
|
||||||
|
cd ~/food-market-stage/deploy && docker compose -p food-market-stage up -d --force-recreate api
|
||||||
|
```
|
||||||
|
|
||||||
|
После рестарта `EffectivelyEnabled=false`, endpoint снова 404, master-password
|
||||||
|
не работает, UI-блок исчезает. См. `docs/quick-login.md` для деталей.
|
||||||
145
docs/quick-login.md
Normal file
145
docs/quick-login.md
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
# Quick Login — быстрый вход на тестовом стенде
|
||||||
|
|
||||||
|
**Статус:** включено на `https://test.admin.food-market.kz` (стенд).
|
||||||
|
**Прод:** `https://admin.food-market.kz` — выключено, попытки активации
|
||||||
|
принудительно блокируются safety-override'ом в коде.
|
||||||
|
|
||||||
|
Это dev-удобство: на стенде не нужно каждый раз вводить логин+пароль.
|
||||||
|
Выбираешь карточку аккаунта на `/login`, клик — ты внутри.
|
||||||
|
|
||||||
|
## Как это работает
|
||||||
|
|
||||||
|
### Server-side
|
||||||
|
|
||||||
|
Опции в `appsettings.json` / env:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
QuickLogin:
|
||||||
|
Enabled: false # default — выключено
|
||||||
|
MasterPassword: "123456"
|
||||||
|
```
|
||||||
|
|
||||||
|
Включается через env в stage-compose:
|
||||||
|
|
||||||
|
```
|
||||||
|
QUICK_LOGIN_ENABLED=true
|
||||||
|
QUICK_LOGIN_MASTER_PASSWORD=123456
|
||||||
|
```
|
||||||
|
|
||||||
|
Логика работает в двух местах:
|
||||||
|
|
||||||
|
- **`POST /connect/token` (grant=password)** — при `EffectivelyEnabled=true`
|
||||||
|
пароль `MasterPassword` принимается у ЛЮБОГО активного пользователя наравне
|
||||||
|
с его собственным паролем. Константное сравнение (`CryptographicOperations.
|
||||||
|
FixedTimeEquals`), чтобы не светить длину. Деактивированные (`IsActive=
|
||||||
|
false`) не пускаются ни при каких условиях. `/connect/token` всё так же
|
||||||
|
гонит через rate-limit, 2FA и org-check (архивная org отшивает).
|
||||||
|
|
||||||
|
- **`GET /api/dev/quick-login/accounts`** — анонимный эндпоинт, который
|
||||||
|
возвращает «человеческие» аккаунты для карточек: SuperAdmin → demo-trio →
|
||||||
|
остальные. Автотестовые логины отсеиваются по regex-патéрнам (всё с
|
||||||
|
суффиксом из 10+ цифр или prefix cat31-/visual-/factory-smoke-/mt6*/
|
||||||
|
quality-/...). Поиск `?q=` фильтрует по email/ФИО/роли/магазину.
|
||||||
|
На проде эндпоинт отдаёт 404 — наличие пути не палит окружение.
|
||||||
|
|
||||||
|
### Client-side (SPA)
|
||||||
|
|
||||||
|
- `<QuickLoginBlock />` над формой логина сам опрашивает `/api/dev/quick-
|
||||||
|
login/accounts`. 200 → рендерит жёлтый блок с карточками + поиск. 404 →
|
||||||
|
возвращает `null`, никакого UI не рисует. Клик по карточке → login с
|
||||||
|
`MasterPassword`.
|
||||||
|
- `<StageBanner />` жёлтой полосой сверху всех страниц, когда эндпоинт
|
||||||
|
доступен. При том же сигнале динамически добавляет `<meta name="robots"
|
||||||
|
content="noindex,nofollow">` — чтобы Google не индексировал стенд.
|
||||||
|
|
||||||
|
Один web-билд используется и в проде, и на стенде — секрет на проде не
|
||||||
|
утекает: `/api/dev/quick-login/accounts` там 404, значит блока нет.
|
||||||
|
|
||||||
|
## Безопасность — предохранители
|
||||||
|
|
||||||
|
1. **Enabled=false по умолчанию.** Нужна явная env-переменная, чтобы
|
||||||
|
включить. В прод-compose её нет и не должно быть.
|
||||||
|
2. **Issuer safety-override.** В `QuickLoginOptions.EffectivelyEnabled`:
|
||||||
|
если `OpenIddict:Issuer` указывает на `admin.food-market.kz` (ТОЧНО,
|
||||||
|
без поддоменов), быстрый вход **принудительно выключен**. Это защищает
|
||||||
|
от ошибки конфигурации — поставили `QUICK_LOGIN_ENABLED=true` в
|
||||||
|
прод-env по ошибке, но Issuer остался прод-овым → ничего не включилось,
|
||||||
|
в лог Warning.
|
||||||
|
3. **Пустой или кривой Issuer тоже отбивает.** Не рискуем в неизвестном
|
||||||
|
окружении: если Issuer пустой/мусорный — считаем выключенным.
|
||||||
|
4. **Rate-limit не ослаблен.** Master-password идёт через ту же защиту,
|
||||||
|
что обычный password-grant (`UseAuthFormUsernameKey` + `UseRateLimiter`).
|
||||||
|
5. **Неактивные пользователи не пускаются.** Проверка `IsActive` сверху
|
||||||
|
`/connect/token` — master-password это не обходит.
|
||||||
|
6. **Startup Warning.** Program.cs логирует одно решение на старте:
|
||||||
|
«включён для стенда» или «Enabled=true, но безопасность выключила».
|
||||||
|
В логах прод-инстанса всегда видно, что происходит.
|
||||||
|
|
||||||
|
## Демо-аккаунты
|
||||||
|
|
||||||
|
`QuickLoginDemoSeeder` при старте с `EffectivelyEnabled=true` поднимает:
|
||||||
|
|
||||||
|
- Организацию **«Демо-магазин»** (идемпотентно; отдельная от «Demo Market»
|
||||||
|
из `DevDataSeeder`, чтобы не ломать e2e).
|
||||||
|
- Полный demo-каталог через `DemoTenantSeeder` (50 товаров / 30 продаж /
|
||||||
|
приёмки / контрагенты).
|
||||||
|
- Трио учёток с ролями:
|
||||||
|
- `demo-admin@food-market.local` → `Admin`
|
||||||
|
- `demo-cashier@food-market.local` → `Cashier`
|
||||||
|
- `demo-keeper@food-market.local` → `Storekeeper`
|
||||||
|
|
||||||
|
Собственный пароль у них `Admin12345!` (унаследовано от шаблона), но
|
||||||
|
мастер-пароль `123456` тоже работает.
|
||||||
|
|
||||||
|
## Чистка автотестового мусора
|
||||||
|
|
||||||
|
`HousekeepingJobs.PruneAutoTestEntitiesAsync` — Hangfire-recurring каждый
|
||||||
|
день в 02:45 UTC. Удаляет автотестовые организации (regex-match по имени)
|
||||||
|
и orphan-юзеры по email-паттерну старше 3 дней. Защищены: SuperAdmin,
|
||||||
|
`admin@demo-market.local`, `demo-*@food-market.local`, `market@email.com`,
|
||||||
|
«Demo Market» / «Демо-магазин».
|
||||||
|
|
||||||
|
Единоразовая зачистка той же логикой — `deploy/stage-prune-autotest.sql`.
|
||||||
|
|
||||||
|
## Как выключить обратно
|
||||||
|
|
||||||
|
На стенде:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# на prod-vm
|
||||||
|
nns@192.168.1.190:$ sed -i '/^QUICK_LOGIN_ENABLED=/d; /^QUICK_LOGIN_MASTER_PASSWORD=/d' \
|
||||||
|
~/food-market-stage/deploy/.env
|
||||||
|
nns@192.168.1.190:$ cd ~/food-market-stage/deploy && docker compose -p food-market-stage up -d --force-recreate api
|
||||||
|
```
|
||||||
|
|
||||||
|
API перестартует, `EffectivelyEnabled` увидит `Enabled=false`, эндпоинт
|
||||||
|
снова 404, master-password не работает. Карточки на `/login` исчезнут —
|
||||||
|
`QuickLoginBlock` увидит 404 и вернёт `null`.
|
||||||
|
|
||||||
|
## Проверить что прод не задет
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w "%{http_code}\n" \
|
||||||
|
https://admin.food-market.kz/api/dev/quick-login/accounts
|
||||||
|
# ожидается: 404
|
||||||
|
```
|
||||||
|
|
||||||
|
Любой другой код (200/403/500) — проблема.
|
||||||
|
|
||||||
|
## Файлы
|
||||||
|
|
||||||
|
| Что | Где |
|
||||||
|
|---|---|
|
||||||
|
| Опции + safety-override | `src/food-market.api/Infrastructure/QuickLoginOptions.cs` |
|
||||||
|
| Master-password в token-grant | `src/food-market.api/Controllers/AuthorizationController.cs` |
|
||||||
|
| Список аккаунтов | `src/food-market.api/Controllers/QuickLoginController.cs` |
|
||||||
|
| Демо-сид | `src/food-market.api/Seed/QuickLoginDemoSeeder.cs` |
|
||||||
|
| Чистка автотестов | `src/food-market.api/Background/HousekeepingJobs.cs` + `deploy/stage-prune-autotest.sql` |
|
||||||
|
| UI блок | `src/food-market.web/src/components/QuickLoginBlock.tsx` |
|
||||||
|
| Плашка стенда | `src/food-market.web/src/components/StageBanner.tsx` |
|
||||||
|
| Integration-тесты | `tests/food-market.IntegrationTests/QuickLoginTests.cs` |
|
||||||
|
| E2E-сценарий | `tests/e2e/scenarios/stage-quick-login.{yml,steps.ts}` |
|
||||||
|
|
||||||
|
## История
|
||||||
|
|
||||||
|
- **2026-10-04 (Sprint 30):** первоначальный ввод на тестовый стенд.
|
||||||
|
|
@ -72,6 +72,16 @@ public Task StartAsync(CancellationToken ct)
|
||||||
cronExpression: cronQualityOrgs,
|
cronExpression: cronQualityOrgs,
|
||||||
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||||
|
|
||||||
|
// Sprint 30: широкий cleanup всех autotest-органа/юзеров (e2e, perf,
|
||||||
|
// smoke, a11y, ratetest, mt6*, cat3*, ...). Default 02:45 UTC
|
||||||
|
// (между prune-quality 02:30 и prune-stock 03:30).
|
||||||
|
var cronAutoTest = _cfg["Hangfire:Cron:PruneAutoTestEntities"] ?? "45 2 * * *";
|
||||||
|
_jobs.AddOrUpdate<HousekeepingJobs>(
|
||||||
|
recurringJobId: "prune-autotest-entities",
|
||||||
|
methodCall: j => j.PruneAutoTestEntitiesAsync(CancellationToken.None),
|
||||||
|
cronExpression: cronAutoTest,
|
||||||
|
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||||
|
|
||||||
// Sprint 20: weekly VACUUM ANALYZE топ-5 таблиц + ежечасный disk-monitor.
|
// Sprint 20: weekly VACUUM ANALYZE топ-5 таблиц + ежечасный disk-monitor.
|
||||||
var cronVacuum = _cfg["Hangfire:Cron:VacuumTopTables"] ?? "0 4 * * 0"; // Воскресенье 04:00 UTC
|
var cronVacuum = _cfg["Hangfire:Cron:VacuumTopTables"] ?? "0 4 * * 0"; // Воскресенье 04:00 UTC
|
||||||
var cronDisk = _cfg["Hangfire:Cron:DiskMonitor"] ?? "0 * * * *"; // каждый час
|
var cronDisk = _cfg["Hangfire:Cron:DiskMonitor"] ?? "0 * * * *"; // каждый час
|
||||||
|
|
|
||||||
|
|
@ -254,4 +254,228 @@ SELECT c.table_name
|
||||||
ids.Count, minHours);
|
ids.Count, minHours);
|
||||||
return ids.Count;
|
return ids.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Sprint 30: расширение cleanup'а на ВСЕ автотестовые орги/юзеры
|
||||||
|
/// (не только <c>quality-*</c>): e2e, perf, smoke, a11y, ratetest, mt6*,
|
||||||
|
/// cat3*, visual-*, factory-smoke-*, auth2*, doc4*, rep5*, rt8*, wiz9*,
|
||||||
|
/// i18n*, twofa*, iso*, mob14*, mob15*, axdet-*, probe-*, load-*,
|
||||||
|
/// s19-test-* и всё с суффиксом из ≥10 цифр.
|
||||||
|
///
|
||||||
|
/// Запускается тем же механизмом (Hangfire recurring) что
|
||||||
|
/// <see cref="PruneQualityTestOrgsAsync"/>. Порог давности
|
||||||
|
/// <c>Cleanup:AutoTestOrgDays</c> (default 3) — чтобы живые e2e-прогоны
|
||||||
|
/// успевали создать-использовать org и завершиться.
|
||||||
|
///
|
||||||
|
/// Защищены от удаления:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Demo Market и Демо-магазин (seeded) + любые орги с именем
|
||||||
|
/// ровно <c>Demo Market</c> / <c>Демо-магазин</c>;</item>
|
||||||
|
/// <item>demo-admin@/demo-cashier@/demo-keeper@ юзеры;</item>
|
||||||
|
/// <item>SuperAdmin (без OrganizationId) — не удаляется ни при каких
|
||||||
|
/// условиях (условие OrganizationId IS NOT NULL в юзер-DELETE);</item>
|
||||||
|
/// <item>market@email.com — явное исключение по email.</item>
|
||||||
|
/// </list></summary>
|
||||||
|
public async Task<int> PruneAutoTestEntitiesAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var minDays = _cfg.GetValue("Cleanup:AutoTestOrgDays", 3);
|
||||||
|
var threshold = DateTime.UtcNow.AddDays(-minDays);
|
||||||
|
|
||||||
|
// Regex в PG: '~*' = case-insensitive match. Собираем выражение по
|
||||||
|
// «автотестовым» префиксам/признакам, аналогично QuickLoginController.
|
||||||
|
// Условие на имя орги. ВАЖНО: даже «просто цифры в конце» ловит почти
|
||||||
|
// любой e2e-прогон, который суффиксит имя timestamp'ом.
|
||||||
|
const string autoTestOrgRx =
|
||||||
|
@"[0-9]{10,}" + // любой длинный цифровой суффикс
|
||||||
|
@"|^quality-" +
|
||||||
|
@"|^soak-" +
|
||||||
|
@"|^(ui-)?a11y-" +
|
||||||
|
@"|^smokeorg[ab]?-" +
|
||||||
|
@"|^perfbaseline-" +
|
||||||
|
@"|^reg-" +
|
||||||
|
@"|^rt-ratetest-" +
|
||||||
|
@"|^probe\s" + // "Probe Org"
|
||||||
|
@"|^cat3[0-9]-" +
|
||||||
|
@"|^visual-" +
|
||||||
|
@"|^factory-smoke-" +
|
||||||
|
@"|^mt6[0-9]-" +
|
||||||
|
@"|^doc4[0-9]-" +
|
||||||
|
@"|^auth2[0-9]-" +
|
||||||
|
@"|^rep5[0-9]-" +
|
||||||
|
@"|^rt8[0-9]-" +
|
||||||
|
@"|^wiz9[0-9]-" +
|
||||||
|
@"|^i18n[0-9]+-" +
|
||||||
|
@"|^twofa[0-9]+-" +
|
||||||
|
@"|^iso[a-z]*[0-9]*-" +
|
||||||
|
@"|^mob1[0-9]-" +
|
||||||
|
@"|^axdet-" +
|
||||||
|
@"|^load-sales-" +
|
||||||
|
@"|^load-" +
|
||||||
|
@"|^s19-test-";
|
||||||
|
|
||||||
|
// SqlQueryRaw<Guid> — удобный способ вытащить только Id без mapping'а
|
||||||
|
// к entity. Параметры через FormattableString небезопасно соединять
|
||||||
|
// (regex из конфига → потенциальный inject), поэтому используем Npgsql-
|
||||||
|
// параметры напрямую.
|
||||||
|
var orgIdSql = @"
|
||||||
|
SELECT ""Id"" FROM public.organizations
|
||||||
|
WHERE ""Name"" <> 'Demo Market'
|
||||||
|
AND ""Name"" <> 'Демо-магазин'
|
||||||
|
AND ""Name"" ~* @rx
|
||||||
|
AND ""CreatedAt"" < @thr
|
||||||
|
LIMIT 20000";
|
||||||
|
var ids = await _db.Database.SqlQueryRaw<Guid>(orgIdSql,
|
||||||
|
new Npgsql.NpgsqlParameter("@rx", autoTestOrgRx),
|
||||||
|
new Npgsql.NpgsqlParameter("@thr", threshold))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (ids.Count == 0)
|
||||||
|
{
|
||||||
|
_log.LogInformation("Hangfire/PruneAutoTestEntities: нет org-кандидатов старше {Days}д", minDays);
|
||||||
|
// Юзеры без org (orphan-autotest) всё равно могут остаться — обрабатываем ниже.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тот же трюк с итеративным DELETE по information_schema, что в
|
||||||
|
// PruneQualityTestOrgsAsync. Юзеры дополнительно вычищаются по
|
||||||
|
// регулярке email'а (широкий set autotest-паттернов).
|
||||||
|
var sql = $@"
|
||||||
|
DO $do$
|
||||||
|
DECLARE
|
||||||
|
org_ids uuid[] := ARRAY[{(ids.Count == 0 ? "NULL::uuid" : string.Join(",", ids.Select(i => $"'{i}'::uuid")))}];
|
||||||
|
tbl text;
|
||||||
|
pass int := 0;
|
||||||
|
remaining int;
|
||||||
|
autotest_user_rx text := '^(cat3[0-9]|visual|mt6[0-9]|factory|doc4[0-9]|auth2[0-9]|rep5[0-9]|rt8[0-9]|wiz9[0-9]|quality|i18n[0-9]+|twofa[0-9]+|a11y|mob1[0-9]|iso|axdet|smoke|ratetest|perf|probe|load|s19-test|admin-[0-9]|cashier-[0-9]|keeper-[0-9]|reg-mt)';
|
||||||
|
autotest_domain_rx text := '(@test-fm\.local|@e\.kz)$';
|
||||||
|
threshold_ts timestamp with time zone := '{threshold:O}'::timestamp with time zone;
|
||||||
|
BEGIN
|
||||||
|
IF array_length(org_ids, 1) IS NOT NULL AND org_ids[1] IS NOT NULL THEN
|
||||||
|
-- 1. Итеративная очистка tenant-таблиц.
|
||||||
|
FOR pass IN 1..10 LOOP
|
||||||
|
remaining := 0;
|
||||||
|
FOR tbl IN
|
||||||
|
SELECT c.table_name
|
||||||
|
FROM information_schema.columns c
|
||||||
|
JOIN information_schema.tables t
|
||||||
|
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
|
||||||
|
WHERE c.table_schema = 'public'
|
||||||
|
AND c.column_name = 'OrganizationId'
|
||||||
|
AND t.table_type = 'BASE TABLE'
|
||||||
|
AND c.table_name NOT IN ('organizations', 'users')
|
||||||
|
LOOP
|
||||||
|
BEGIN
|
||||||
|
EXECUTE format('DELETE FROM public.%I WHERE ""OrganizationId"" = ANY($1)', tbl) USING org_ids;
|
||||||
|
EXCEPTION WHEN foreign_key_violation THEN
|
||||||
|
remaining := remaining + 1;
|
||||||
|
END;
|
||||||
|
END LOOP;
|
||||||
|
EXIT WHEN remaining = 0;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- 2. AspNet* / OpenIddict для юзеров этих org.
|
||||||
|
DELETE FROM ""AspNetUserRoles"" WHERE ""UserId"" IN (
|
||||||
|
SELECT u.""Id"" FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids)
|
||||||
|
);
|
||||||
|
DELETE FROM ""AspNetUserTokens"" WHERE ""UserId"" IN (
|
||||||
|
SELECT u.""Id"" FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids)
|
||||||
|
);
|
||||||
|
DELETE FROM ""AspNetUserClaims"" WHERE ""UserId"" IN (
|
||||||
|
SELECT u.""Id"" FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids)
|
||||||
|
);
|
||||||
|
DELETE FROM ""AspNetUserLogins"" WHERE ""UserId"" IN (
|
||||||
|
SELECT u.""Id"" FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids)
|
||||||
|
);
|
||||||
|
DELETE FROM ""OpenIddictTokens"" WHERE ""Subject"" IN (
|
||||||
|
SELECT u.""Id""::text FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids)
|
||||||
|
);
|
||||||
|
DELETE FROM ""OpenIddictAuthorizations"" WHERE ""Subject"" IN (
|
||||||
|
SELECT u.""Id""::text FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. Юзеры этих org.
|
||||||
|
DELETE FROM public.users WHERE ""OrganizationId"" = ANY(org_ids);
|
||||||
|
|
||||||
|
-- 4. Сами organizations.
|
||||||
|
DELETE FROM public.organizations WHERE ""Id"" = ANY(org_ids);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 5. Orphan-юзеры: autotest-паттерн email + не SuperAdmin (OrganizationId
|
||||||
|
-- IS NOT NULL) + не demo-* + не market@email.com + создан раньше threshold.
|
||||||
|
-- Эти юзеры могут остаться когда org уже удалена, но AspNetUserRoles —
|
||||||
|
-- нет (если был cascade NOT), либо если тест не создал org вообще.
|
||||||
|
DELETE FROM ""AspNetUserRoles"" WHERE ""UserId"" IN (
|
||||||
|
SELECT u.""Id"" FROM public.users u
|
||||||
|
WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}')
|
||||||
|
AND u.""Email"" NOT LIKE 'demo-%@food-market.local'
|
||||||
|
AND u.""Email"" <> 'admin@food-market.local'
|
||||||
|
AND u.""Email"" <> 'admin@demo-market.local'
|
||||||
|
AND u.""Email"" <> 'market@email.com'
|
||||||
|
AND u.""OrganizationId"" IS NOT NULL
|
||||||
|
AND u.""CreatedAt"" < threshold_ts
|
||||||
|
);
|
||||||
|
DELETE FROM ""AspNetUserTokens"" WHERE ""UserId"" IN (
|
||||||
|
SELECT u.""Id"" FROM public.users u
|
||||||
|
WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}')
|
||||||
|
AND u.""Email"" NOT LIKE 'demo-%@food-market.local'
|
||||||
|
AND u.""Email"" <> 'admin@food-market.local'
|
||||||
|
AND u.""Email"" <> 'admin@demo-market.local'
|
||||||
|
AND u.""Email"" <> 'market@email.com'
|
||||||
|
AND u.""OrganizationId"" IS NOT NULL
|
||||||
|
AND u.""CreatedAt"" < threshold_ts
|
||||||
|
);
|
||||||
|
DELETE FROM ""AspNetUserClaims"" WHERE ""UserId"" IN (
|
||||||
|
SELECT u.""Id"" FROM public.users u
|
||||||
|
WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}')
|
||||||
|
AND u.""Email"" NOT LIKE 'demo-%@food-market.local'
|
||||||
|
AND u.""Email"" <> 'admin@food-market.local'
|
||||||
|
AND u.""Email"" <> 'admin@demo-market.local'
|
||||||
|
AND u.""Email"" <> 'market@email.com'
|
||||||
|
AND u.""OrganizationId"" IS NOT NULL
|
||||||
|
AND u.""CreatedAt"" < threshold_ts
|
||||||
|
);
|
||||||
|
DELETE FROM ""AspNetUserLogins"" WHERE ""UserId"" IN (
|
||||||
|
SELECT u.""Id"" FROM public.users u
|
||||||
|
WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}')
|
||||||
|
AND u.""Email"" NOT LIKE 'demo-%@food-market.local'
|
||||||
|
AND u.""Email"" <> 'admin@food-market.local'
|
||||||
|
AND u.""Email"" <> 'admin@demo-market.local'
|
||||||
|
AND u.""Email"" <> 'market@email.com'
|
||||||
|
AND u.""OrganizationId"" IS NOT NULL
|
||||||
|
AND u.""CreatedAt"" < threshold_ts
|
||||||
|
);
|
||||||
|
DELETE FROM ""OpenIddictTokens"" WHERE ""Subject"" IN (
|
||||||
|
SELECT u.""Id""::text FROM public.users u
|
||||||
|
WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}')
|
||||||
|
AND u.""Email"" NOT LIKE 'demo-%@food-market.local'
|
||||||
|
AND u.""Email"" <> 'admin@food-market.local'
|
||||||
|
AND u.""Email"" <> 'admin@demo-market.local'
|
||||||
|
AND u.""Email"" <> 'market@email.com'
|
||||||
|
AND u.""OrganizationId"" IS NOT NULL
|
||||||
|
AND u.""CreatedAt"" < threshold_ts
|
||||||
|
);
|
||||||
|
DELETE FROM ""OpenIddictAuthorizations"" WHERE ""Subject"" IN (
|
||||||
|
SELECT u.""Id""::text FROM public.users u
|
||||||
|
WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}')
|
||||||
|
AND u.""Email"" NOT LIKE 'demo-%@food-market.local'
|
||||||
|
AND u.""Email"" <> 'admin@food-market.local'
|
||||||
|
AND u.""Email"" <> 'admin@demo-market.local'
|
||||||
|
AND u.""Email"" <> 'market@email.com'
|
||||||
|
AND u.""OrganizationId"" IS NOT NULL
|
||||||
|
AND u.""CreatedAt"" < threshold_ts
|
||||||
|
);
|
||||||
|
DELETE FROM public.users u
|
||||||
|
WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}')
|
||||||
|
AND u.""Email"" NOT LIKE 'demo-%@food-market.local'
|
||||||
|
AND u.""Email"" <> 'admin@food-market.local'
|
||||||
|
AND u.""Email"" <> 'admin@demo-market.local'
|
||||||
|
AND u.""Email"" <> 'market@email.com'
|
||||||
|
AND u.""OrganizationId"" IS NOT NULL
|
||||||
|
AND u.""CreatedAt"" < threshold_ts;
|
||||||
|
END $do$;
|
||||||
|
";
|
||||||
|
|
||||||
|
await _db.Database.ExecuteSqlRawAsync(sql, ct);
|
||||||
|
_log.LogInformation("Hangfire/PruneAutoTestEntities: удалено {Count} org (старше {Days}д) + orphan autotest-юзеры",
|
||||||
|
ids.Count, minDays);
|
||||||
|
return ids.Count;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using foodmarket.Api.Infrastructure;
|
||||||
using foodmarket.Api.Infrastructure.Tenancy;
|
using foodmarket.Api.Infrastructure.Tenancy;
|
||||||
using foodmarket.Infrastructure.Identity;
|
using foodmarket.Infrastructure.Identity;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
|
@ -8,6 +9,7 @@
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using OpenIddict.Abstractions;
|
using OpenIddict.Abstractions;
|
||||||
using OpenIddict.Server.AspNetCore;
|
using OpenIddict.Server.AspNetCore;
|
||||||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||||
|
|
@ -20,12 +22,24 @@ public class AuthorizationController : ControllerBase
|
||||||
private readonly SignInManager<User> _signInManager;
|
private readonly SignInManager<User> _signInManager;
|
||||||
private readonly UserManager<User> _userManager;
|
private readonly UserManager<User> _userManager;
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
|
private readonly QuickLoginOptions _quickLogin;
|
||||||
|
private readonly string? _issuer;
|
||||||
|
private readonly ILogger<AuthorizationController> _log;
|
||||||
|
|
||||||
public AuthorizationController(SignInManager<User> signInManager, UserManager<User> userManager, AppDbContext db)
|
public AuthorizationController(
|
||||||
|
SignInManager<User> signInManager,
|
||||||
|
UserManager<User> userManager,
|
||||||
|
AppDbContext db,
|
||||||
|
IOptions<QuickLoginOptions> quickLogin,
|
||||||
|
IConfiguration cfg,
|
||||||
|
ILogger<AuthorizationController> log)
|
||||||
{
|
{
|
||||||
_signInManager = signInManager;
|
_signInManager = signInManager;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_db = db;
|
_db = db;
|
||||||
|
_quickLogin = quickLogin.Value;
|
||||||
|
_issuer = cfg["OpenIddict:Issuer"];
|
||||||
|
_log = log;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("~/connect/token"), Produces("application/json")]
|
[HttpPost("~/connect/token"), Produces("application/json")]
|
||||||
|
|
@ -45,7 +59,23 @@ public async Task<IActionResult> Exchange()
|
||||||
var result = await _signInManager.CheckPasswordSignInAsync(user, request.Password!, true);
|
var result = await _signInManager.CheckPasswordSignInAsync(user, request.Password!, true);
|
||||||
if (!result.Succeeded)
|
if (!result.Succeeded)
|
||||||
{
|
{
|
||||||
return BadRequestError(Errors.InvalidGrant, "Неверный логин или пароль.");
|
// Sprint 30: мастер-пароль «быстрого входа» на тестовом стенде.
|
||||||
|
// Срабатывает ТОЛЬКО если QuickLogin:Enabled=true И Issuer не прод.
|
||||||
|
// Сравнение через FixedTimeEquals — чтобы не светить длину по времени.
|
||||||
|
// Rate-limit в UseAuthFormUsernameKey + UseRateLimiter в Program.cs
|
||||||
|
// уже отработал ДО нас, этот путь ничего не ослабляет.
|
||||||
|
if (_quickLogin.EffectivelyEnabled(_issuer, out _)
|
||||||
|
&& IsMasterPassword(request.Password))
|
||||||
|
{
|
||||||
|
_log.LogWarning("QuickLogin: мастер-пароль использован для {User} — только для стенда", user.UserName);
|
||||||
|
// Пропускаем Identity-lockout апдейт: это не настоящий mismatched
|
||||||
|
// password и не brute-force (rate-limit выше уже страхует). Просто
|
||||||
|
// идём дальше по обычному пути (2FA, org-check, principal).
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return BadRequestError(Errors.InvalidGrant, "Неверный логин или пароль.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2FA: если у юзера включён TOTP, требуем otp_code в запросе
|
// 2FA: если у юзера включён TOTP, требуем otp_code в запросе
|
||||||
|
|
@ -174,6 +204,19 @@ or HttpContextTenantContext.OrganizationClaim
|
||||||
[Parameters.Error] = error,
|
[Parameters.Error] = error,
|
||||||
[Parameters.ErrorDescription] = description
|
[Parameters.ErrorDescription] = description
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// <summary>Константное сравнение пароля с мастер-ключом. Пустой пароль
|
||||||
|
/// и пустой мастер-ключ отфутболиваются ДО FixedTimeEquals, чтобы случайно
|
||||||
|
/// не пустить любого, кто прислал "" в деконфигурированном окружении.</summary>
|
||||||
|
private bool IsMasterPassword(string? supplied)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(supplied)) return false;
|
||||||
|
if (string.IsNullOrEmpty(_quickLogin.MasterPassword)) return false;
|
||||||
|
var a = System.Text.Encoding.UTF8.GetBytes(supplied);
|
||||||
|
var b = System.Text.Encoding.UTF8.GetBytes(_quickLogin.MasterPassword);
|
||||||
|
if (a.Length != b.Length) return false;
|
||||||
|
return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(a, b);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static class TokenValidationParameters
|
internal static class TokenValidationParameters
|
||||||
|
|
|
||||||
184
src/food-market.api/Controllers/QuickLoginController.cs
Normal file
184
src/food-market.api/Controllers/QuickLoginController.cs
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
using foodmarket.Api.Infrastructure;
|
||||||
|
using foodmarket.Infrastructure.Identity;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Dev-only список аккаунтов для «быстрого входа» на тестовом стенде.
|
||||||
|
/// Анонимный, но доступен ТОЛЬКО когда <see cref="QuickLoginOptions.EffectivelyEnabled"/>
|
||||||
|
/// = true. В проде отдаёт 404 — ни отфильтрованных, ни пустых ответов, чтобы
|
||||||
|
/// по наличию эндпоинта нельзя было вычислить среду.
|
||||||
|
///
|
||||||
|
/// Что отдаём: активных «человеческих» пользователей — SuperAdmin, затем
|
||||||
|
/// demo-* (seeded), затем остальные. Автотестовые (с числовым суффиксом или
|
||||||
|
/// служебным префиксом) отсеиваются по шаблонам из <see cref="AutoTestPatterns"/>.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[Route("api/dev/quick-login")]
|
||||||
|
public class QuickLoginController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly UserManager<User> _userManager;
|
||||||
|
private readonly QuickLoginOptions _opts;
|
||||||
|
private readonly string? _issuer;
|
||||||
|
|
||||||
|
public QuickLoginController(
|
||||||
|
AppDbContext db,
|
||||||
|
UserManager<User> userManager,
|
||||||
|
IOptions<QuickLoginOptions> opts,
|
||||||
|
IConfiguration cfg)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userManager = userManager;
|
||||||
|
_opts = opts.Value;
|
||||||
|
_issuer = cfg["OpenIddict:Issuer"];
|
||||||
|
}
|
||||||
|
|
||||||
|
public record QuickLoginAccount(
|
||||||
|
string Email,
|
||||||
|
string FullName,
|
||||||
|
string? Role,
|
||||||
|
string? OrganizationName,
|
||||||
|
bool IsSuperAdmin,
|
||||||
|
bool IsDemo);
|
||||||
|
|
||||||
|
/// <summary>Шаблоны логинов, которые считаем автотестовыми и не показываем
|
||||||
|
/// в списке. Проверка — case-insensitive, по началу email'а.</summary>
|
||||||
|
private static readonly string[] AutoTestPatterns =
|
||||||
|
{
|
||||||
|
"cat31-", "cat32-", "cat33-", "cat34-", "cat35-",
|
||||||
|
"visual-", "factory-smoke-", "factory-",
|
||||||
|
"mt61-", "mt62-", "mt63-",
|
||||||
|
"doc41-", "doc42-", "doc43-", "doc44-", "doc45-", "doc46-", "doc47-", "doc48-",
|
||||||
|
"auth21-", "auth22-", "auth23-", "auth24-",
|
||||||
|
"rep51-", "rep52-", "rep53-", "rep54-",
|
||||||
|
"rt81-", "rt82-", "rt83-", "rt84-",
|
||||||
|
"wiz91-", "wiz92-", "wiz93-", "wiz94-", "wiz95-", "wiz-",
|
||||||
|
"quality-", "i18n71-", "i18n72-", "twofa73-",
|
||||||
|
"a11y-", "mob14", "mob15", "iso",
|
||||||
|
"axdet-", "smoke-", "ratetest-", "perf-", "probe-",
|
||||||
|
"load-sales-", "load-", "s19-test-",
|
||||||
|
"admin-", "cashier-", "keeper-", // seeded test triples по id
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Email-домены, которые считаем «служебными» для автотестов.
|
||||||
|
/// На стенде реальные юзеры создаются только с @food-market.local и
|
||||||
|
/// @demo-market.local, остальное — автоматика.</summary>
|
||||||
|
private static readonly string[] AutoTestDomains =
|
||||||
|
{
|
||||||
|
"@e.kz", // короткие diagnostic-probes (dn29418@e.kz и т.п.)
|
||||||
|
"@test-fm.local", // quality-watchdog
|
||||||
|
};
|
||||||
|
|
||||||
|
[HttpGet("accounts")]
|
||||||
|
public async Task<IActionResult> GetAccounts([FromQuery] string? q, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!_opts.EffectivelyEnabled(_issuer, out _))
|
||||||
|
{
|
||||||
|
// 404 — не 403: наличие эндпоинта не должно palятся в проде. По
|
||||||
|
// шаблону Admin-only эндпоинтов, где отключение = скрытие.
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Активные + с org (SuperAdmin — без org). IsActive=false не пускаем,
|
||||||
|
// как и в password-grant: быстрый вход для «уволенного» = бессмыслица.
|
||||||
|
var users = await _db.Users
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(u => u.IsActive)
|
||||||
|
.Select(u => new
|
||||||
|
{
|
||||||
|
u.Id,
|
||||||
|
u.UserName,
|
||||||
|
u.Email,
|
||||||
|
u.FullName,
|
||||||
|
u.OrganizationId,
|
||||||
|
OrgName = u.OrganizationId == null
|
||||||
|
? null
|
||||||
|
: _db.Organizations.IgnoreQueryFilters()
|
||||||
|
.Where(o => o.Id == u.OrganizationId && !o.IsArchived)
|
||||||
|
.Select(o => o.Name).FirstOrDefault(),
|
||||||
|
// Роль в organization — через Employee.RoleId → EmployeeRole.Name.
|
||||||
|
// Берём первую активную (у юзера обычно одна Employee-запись).
|
||||||
|
RoleName = _db.Employees.IgnoreQueryFilters()
|
||||||
|
.Where(e => e.UserId == u.Id && e.IsActive && !e.IsDeleted)
|
||||||
|
.Select(e => e.Role.Name)
|
||||||
|
.FirstOrDefault(),
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// ASP.NET Identity role: SuperAdmin не живёт в Employee, а в AspNetUserRoles.
|
||||||
|
// Делаем отдельный проход — JOIN через UserManager, не EF-рефлексией.
|
||||||
|
var superAdminUserIds = (await _userManager.GetUsersInRoleAsync(SystemRoles.SuperAdmin))
|
||||||
|
.Select(u => u.Id)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
var result = users
|
||||||
|
.Where(u => !IsAutoTest(u.Email ?? u.UserName ?? ""))
|
||||||
|
// SuperAdmin и demo-* остаются даже без OrgName (SuperAdmin без org'и).
|
||||||
|
.Select(u =>
|
||||||
|
{
|
||||||
|
var isSa = superAdminUserIds.Contains(u.Id);
|
||||||
|
var isDemo = IsDemoAccount(u.Email ?? u.UserName ?? "");
|
||||||
|
string? role = isSa ? "SuperAdmin" : (u.RoleName ?? null);
|
||||||
|
return new QuickLoginAccount(
|
||||||
|
Email: u.Email ?? u.UserName ?? "",
|
||||||
|
FullName: string.IsNullOrWhiteSpace(u.FullName) ? (u.Email ?? "") : u.FullName,
|
||||||
|
Role: role,
|
||||||
|
OrganizationName: isSa ? "Платформа" : u.OrgName,
|
||||||
|
IsSuperAdmin: isSa,
|
||||||
|
IsDemo: isDemo);
|
||||||
|
})
|
||||||
|
.Where(a =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(q)) return true;
|
||||||
|
var needle = q.Trim().ToLowerInvariant();
|
||||||
|
return a.Email.ToLowerInvariant().Contains(needle)
|
||||||
|
|| (a.FullName ?? "").ToLowerInvariant().Contains(needle)
|
||||||
|
|| (a.OrganizationName ?? "").ToLowerInvariant().Contains(needle)
|
||||||
|
|| (a.Role ?? "").ToLowerInvariant().Contains(needle);
|
||||||
|
})
|
||||||
|
// Порядок: SuperAdmin → demo → остальные (по email для стабильности).
|
||||||
|
.OrderByDescending(a => a.IsSuperAdmin)
|
||||||
|
.ThenByDescending(a => a.IsDemo)
|
||||||
|
.ThenBy(a => a.Email)
|
||||||
|
.Take(50)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Автотестовые юзеры: длинный числовой суффикс (10+ цифр подряд)
|
||||||
|
/// или email начинается с одного из <see cref="AutoTestPatterns"/>.
|
||||||
|
/// Исключения — <c>demo-admin@...</c>/<c>demo-cashier@...</c>/<c>demo-keeper@...</c>
|
||||||
|
/// обрабатываются <see cref="IsDemoAccount"/>, они НЕ matchат ни один из
|
||||||
|
/// autotest-префиксов (нет "admin-", только "demo-admin@").</summary>
|
||||||
|
private static bool IsAutoTest(string email)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(email)) return true;
|
||||||
|
var lower = email.ToLowerInvariant();
|
||||||
|
|
||||||
|
// Длинный числовой суффикс (миллисекундный timestamp 13 цифр, или 10+ цифр
|
||||||
|
// в середине). Любой email вида что-то-1780849101465@... считаем автотестом.
|
||||||
|
var m = System.Text.RegularExpressions.Regex.Match(lower, "[0-9]{10,}");
|
||||||
|
if (m.Success) return true;
|
||||||
|
|
||||||
|
foreach (var p in AutoTestPatterns)
|
||||||
|
{
|
||||||
|
if (lower.StartsWith(p)) return true;
|
||||||
|
}
|
||||||
|
foreach (var d in AutoTestDomains)
|
||||||
|
{
|
||||||
|
if (lower.EndsWith(d)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDemoAccount(string email) =>
|
||||||
|
email.StartsWith("demo-", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& email.Contains("@food-market.local", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
86
src/food-market.api/Infrastructure/QuickLoginOptions.cs
Normal file
86
src/food-market.api/Infrastructure/QuickLoginOptions.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
namespace foodmarket.Api.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>Опции «быстрого входа» на тестовом стенде. По умолчанию выключен.
|
||||||
|
/// Включается ТОЛЬКО переменной окружения <c>QuickLogin__Enabled=true</c>
|
||||||
|
/// в stage-compose. В прод-compose этой переменной нет — флаг остаётся
|
||||||
|
/// в false и ни эндпоинт списка аккаунтов, ни «мастер-пароль» не включаются.
|
||||||
|
///
|
||||||
|
/// Жёсткий предохранитель <see cref="EffectivelyEnabled"/>: даже если флаг
|
||||||
|
/// случайно пробросили в прод, но <c>OpenIddict:Issuer</c> указывает на
|
||||||
|
/// admin.food-market.kz — быстрый вход всё равно выключен. Это защищает от
|
||||||
|
/// ошибок конфигурации (ошибочный set-env на prod-env) — конфиг-опечатка
|
||||||
|
/// не должна открыть прод.</summary>
|
||||||
|
public sealed class QuickLoginOptions
|
||||||
|
{
|
||||||
|
/// <summary>Главный флаг. При false любой код, относящийся к quick-login,
|
||||||
|
/// должен вести себя как в проде (эндпоинт 404, мастер-пароль не работает,
|
||||||
|
/// плашка не рендерится).</summary>
|
||||||
|
public bool Enabled { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>Единый пароль-ключ для стенда. При включённом флаге подходит
|
||||||
|
/// к ЛЮБОМУ активному аккаунту в <c>/connect/token</c> grant=password.
|
||||||
|
/// Собственный пароль пользователя тоже продолжает работать (чтобы не
|
||||||
|
/// ломать e2e, seeded admin и т.д.).</summary>
|
||||||
|
public string MasterPassword { get; set; } = "123456";
|
||||||
|
|
||||||
|
/// <summary>Эффективный флаг с учётом safety-override по Issuer. Используй
|
||||||
|
/// везде, где решается «пускать мастер-пароль / отдавать список». Чтение
|
||||||
|
/// сравнивает Issuer с prod-доменом admin.food-market.kz — если совпадает
|
||||||
|
/// (или Issuer пустой), быстрый вход считается выключенным и в лог
|
||||||
|
/// пишется Warning один раз на старте через <see cref="LogSafetyDecision"/>.</summary>
|
||||||
|
public bool EffectivelyEnabled(string? issuer, out string reason)
|
||||||
|
{
|
||||||
|
if (!Enabled)
|
||||||
|
{
|
||||||
|
reason = "QuickLogin:Enabled=false";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issuer обязателен — если его нет, не рискуем: dev/stage без явного
|
||||||
|
// Issuer не должны открывать мастер-пароль, иначе любое неверно
|
||||||
|
// настроенное окружение превращается в стенд.
|
||||||
|
if (string.IsNullOrWhiteSpace(issuer))
|
||||||
|
{
|
||||||
|
reason = "OpenIddict:Issuer пустой — не могу подтвердить, что это стенд";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Прод-домен в lower-case: Uri сам нормализует хост, но подстрахуем.
|
||||||
|
if (!Uri.TryCreate(issuer, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
reason = $"OpenIddict:Issuer '{issuer}' не парсится как Uri";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var host = uri.Host.ToLowerInvariant();
|
||||||
|
// ТОЛЬКО точное совпадение с прод-хостом. Любой поддомен (test.admin.*,
|
||||||
|
// dev.admin.*) — это стенд, его трогать не надо. Если в будущем
|
||||||
|
// появится второй прод-хост, сюда дописывается новое условие.
|
||||||
|
if (host == "admin.food-market.kz")
|
||||||
|
{
|
||||||
|
reason = $"OpenIddict:Issuer host={host} — это ПРОД, быстрый вход принудительно выключен";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
reason = $"OpenIddict:Issuer host={host} — стенд, быстрый вход включён";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Одноразовый лог при старте приложения. Пишется в лог, чтобы
|
||||||
|
/// в проде всегда было видно: «флаг пришёл, но Issuer=prod, выключили».
|
||||||
|
/// Вызывается из Program.cs после Build().</summary>
|
||||||
|
public void LogSafetyDecision(ILogger logger, string? issuer)
|
||||||
|
{
|
||||||
|
var effective = EffectivelyEnabled(issuer, out var reason);
|
||||||
|
if (effective)
|
||||||
|
{
|
||||||
|
logger.LogWarning("QuickLogin включён для стенда: {Reason}. " +
|
||||||
|
"Это норма для test.admin.food-market.kz. В проде должно быть выключено.", reason);
|
||||||
|
}
|
||||||
|
else if (Enabled)
|
||||||
|
{
|
||||||
|
// Enabled=true, но effective=false — явный safety-переключатель. Это серьёзно.
|
||||||
|
logger.LogWarning("QuickLogin:Enabled=true, но быстрый вход НЕ активирован. {Reason}", reason);
|
||||||
|
}
|
||||||
|
// Если Enabled=false — молчим, это обычный прод.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,11 @@
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<ITenantContext, HttpContextTenantContext>();
|
builder.Services.AddScoped<ITenantContext, HttpContextTenantContext>();
|
||||||
|
|
||||||
|
// Sprint 30: «быстрый вход» на тестовом стенде. Dev-only флаг, выключен
|
||||||
|
// по умолчанию, с safety-override по Issuer (см. QuickLoginOptions).
|
||||||
|
builder.Services.Configure<foodmarket.Api.Infrastructure.QuickLoginOptions>(
|
||||||
|
builder.Configuration.GetSection("QuickLogin"));
|
||||||
|
|
||||||
// Sprint 20 / TD-3: Mapster config — singleton TypeAdapterConfig.
|
// Sprint 20 / TD-3: Mapster config — singleton TypeAdapterConfig.
|
||||||
// Используется в контроллерах через ProjectToType<TDto>(MapsterConfig.Config)
|
// Используется в контроллерах через ProjectToType<TDto>(MapsterConfig.Config)
|
||||||
// для SQL-friendly проекций. IMapper в DI не регистрируем, потому что
|
// для SQL-friendly проекций. IMapper в DI не регистрируем, потому что
|
||||||
|
|
@ -446,6 +451,9 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
builder.Services.AddHostedService<DevDataSeeder>();
|
builder.Services.AddHostedService<DevDataSeeder>();
|
||||||
// Демо-сидер для stage'a (триггерится через POST /api/admin/seed-demo).
|
// Демо-сидер для stage'a (триггерится через POST /api/admin/seed-demo).
|
||||||
builder.Services.AddScoped<foodmarket.Api.Seed.DemoTenantSeeder>();
|
builder.Services.AddScoped<foodmarket.Api.Seed.DemoTenantSeeder>();
|
||||||
|
// Sprint 30: демо-trio «Демо-магазина» для quick-login — только когда флаг
|
||||||
|
// эффективно включён. Внутри сам чекает EffectivelyEnabled().
|
||||||
|
builder.Services.AddHostedService<foodmarket.Api.Seed.QuickLoginDemoSeeder>();
|
||||||
// Расширенный сидер на год операций (POST /api/admin/seed-demo?years=1).
|
// Расширенный сидер на год операций (POST /api/admin/seed-demo?years=1).
|
||||||
builder.Services.AddScoped<foodmarket.Api.Seed.YearDemoSeeder>();
|
builder.Services.AddScoped<foodmarket.Api.Seed.YearDemoSeeder>();
|
||||||
builder.Services.AddHostedService<foodmarket.Api.Background.ReferencePriceRefreshJob>();
|
builder.Services.AddHostedService<foodmarket.Api.Background.ReferencePriceRefreshJob>();
|
||||||
|
|
@ -477,6 +485,17 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Sprint 30: один раз на старте проверяем решение по quick-login
|
||||||
|
// (Enabled+Issuer). Пишет Warning и в прод-env, и в стенде, чтобы в логах
|
||||||
|
// всегда было видно, в каком режиме стартанули.
|
||||||
|
{
|
||||||
|
var ql = app.Services.GetRequiredService<Microsoft.Extensions.Options.IOptions<
|
||||||
|
foodmarket.Api.Infrastructure.QuickLoginOptions>>().Value;
|
||||||
|
var issuer = builder.Configuration["OpenIddict:Issuer"];
|
||||||
|
ql.LogSafetyDecision(app.Services.GetRequiredService<ILoggerFactory>()
|
||||||
|
.CreateLogger("QuickLogin"), issuer);
|
||||||
|
}
|
||||||
|
|
||||||
// HSTS — только когда мы за HTTPS (stage/prod через nginx). В Development
|
// HSTS — только когда мы за HTTPS (stage/prod через nginx). В Development
|
||||||
// обычно работаем по http://localhost:5081, и Strict-Transport-Security
|
// обычно работаем по http://localhost:5081, и Strict-Transport-Security
|
||||||
// прибил бы локальный браузер на год. По дефолту 365 дней + preload.
|
// прибил бы локальный браузер на год. По дефолту 365 дней + preload.
|
||||||
|
|
|
||||||
222
src/food-market.api/Seed/QuickLoginDemoSeeder.cs
Normal file
222
src/food-market.api/Seed/QuickLoginDemoSeeder.cs
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
using foodmarket.Api.Infrastructure;
|
||||||
|
using foodmarket.Domain.Organizations;
|
||||||
|
using foodmarket.Infrastructure.Identity;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Seed;
|
||||||
|
|
||||||
|
/// <summary>Поднимает «Демо-магазин» и трёх демо-сотрудников (Admin / Cashier /
|
||||||
|
/// Storekeeper) ПРИ СТАРТЕ, только когда quick-login эффективно включён
|
||||||
|
/// (<see cref="QuickLoginOptions.EffectivelyEnabled"/>). Это тот фикстурный
|
||||||
|
/// набор аккаунтов, который попадает первым в <c>/api/dev/quick-login/accounts</c>
|
||||||
|
/// под IsDemo=true.
|
||||||
|
///
|
||||||
|
/// Идемпотентно: каждая сущность создаётся через проверку по имени/email,
|
||||||
|
/// плюс полный demo-каталог поднимается через существующий
|
||||||
|
/// <see cref="DemoTenantSeeder"/> (маркер DEMO-* защищает от повторных вставок).
|
||||||
|
///
|
||||||
|
/// Если флаг выключен — Hosted-сервис не делает ничего (включая запись
|
||||||
|
/// в лог), чтобы на проде просто не запускаться.</summary>
|
||||||
|
public sealed class QuickLoginDemoSeeder : IHostedService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly IConfiguration _cfg;
|
||||||
|
private readonly QuickLoginOptions _opts;
|
||||||
|
private readonly ILogger<QuickLoginDemoSeeder> _log;
|
||||||
|
|
||||||
|
public QuickLoginDemoSeeder(
|
||||||
|
IServiceProvider services,
|
||||||
|
IConfiguration cfg,
|
||||||
|
IOptions<QuickLoginOptions> opts,
|
||||||
|
ILogger<QuickLoginDemoSeeder> log)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
_cfg = cfg;
|
||||||
|
_opts = opts.Value;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string OrgName = "Демо-магазин";
|
||||||
|
private const string DemoAdminEmail = "demo-admin@food-market.local";
|
||||||
|
private const string DemoCashierEmail = "demo-cashier@food-market.local";
|
||||||
|
private const string DemoKeeperEmail = "demo-keeper@food-market.local";
|
||||||
|
|
||||||
|
public async Task StartAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var issuer = _cfg["OpenIddict:Issuer"];
|
||||||
|
if (!_opts.EffectivelyEnabled(issuer, out _))
|
||||||
|
{
|
||||||
|
// На проде вообще не запускаемся. В dev без Enabled=true — тоже
|
||||||
|
// не создаём демо-набор, чтобы не отравлять дефолтный localhost.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Весь сидер обёрнут try/catch: dev-удобство для стенда не должно
|
||||||
|
// класть API. Если что-то упало — пишем Warning и выходим. Админ
|
||||||
|
// сможет руками подёргать POST /api/admin/seed-demo или починить
|
||||||
|
// сломанное состояние; стенд при этом остаётся работоспособным.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SeedAsync(ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning(ex, "QuickLoginDemoSeeder: не удалось засеять демо-данные для стенда. " +
|
||||||
|
"Это не критично — API остаётся рабочим, SuperAdmin по-прежнему пускает по мастер-паролю.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeedAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Каждый sub-step использует СВОЙ scope (и, значит, свой DbContext).
|
||||||
|
// Это важно: DemoTenantSeeder может упасть с «Tenant not set»
|
||||||
|
// (нет HTTP-контекста при старте приложения), оставив DbContext в
|
||||||
|
// транзакционно-битом состоянии. Если бы мы переиспользовали один
|
||||||
|
// scope, следующие UserManager.CreateAsync возвращали бы
|
||||||
|
// DbUpdateConcurrencyException. Свежий scope = свежая транзакция.
|
||||||
|
var demoOrgId = await EnsureDemoOrgAsync(ct);
|
||||||
|
|
||||||
|
await EnsureTenantReferencesAsync(demoOrgId, ct);
|
||||||
|
|
||||||
|
await TryFillCatalogAsync(demoOrgId, ct);
|
||||||
|
|
||||||
|
await EnsureDemoUsersAsync(demoOrgId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Guid> EnsureDemoOrgAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var demoOrg = await db.Organizations.IgnoreQueryFilters()
|
||||||
|
.FirstOrDefaultAsync(o => o.Name == OrgName, ct);
|
||||||
|
if (demoOrg is not null) return demoOrg.Id;
|
||||||
|
|
||||||
|
var kzt = await db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct);
|
||||||
|
demoOrg = new Organization
|
||||||
|
{
|
||||||
|
Name = OrgName,
|
||||||
|
CountryCode = "KZ",
|
||||||
|
Bin = "000000000001",
|
||||||
|
Address = "Алматы, ул. Демо-стенд 1",
|
||||||
|
Phone = "+7 (727) 000-00-01",
|
||||||
|
Email = "demo@food-market.local",
|
||||||
|
DefaultCurrencyId = kzt?.Id,
|
||||||
|
};
|
||||||
|
db.Organizations.Add(demoOrg);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
_log.LogInformation("QuickLoginDemoSeeder: создана организация {OrgName}", OrgName);
|
||||||
|
return demoOrg.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureTenantReferencesAsync(Guid orgId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
await DevDataSeeder.SeedTenantReferencesAsync(db, orgId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TryFillCatalogAsync(Guid orgId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// DemoTenantSeeder требует ITenantContext с orgId — при старте без
|
||||||
|
// HTTP-запроса этот контекст пуст, StockService.ApplyMovementAsync
|
||||||
|
// падает с «Tenant not set». Поэтому ловим и игнорируем: катало́г —
|
||||||
|
// nice-to-have, демо-trio юзеров всё равно создастся отдельно.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var catalogSeeder = scope.ServiceProvider.GetRequiredService<DemoTenantSeeder>();
|
||||||
|
var summary = await catalogSeeder.SeedAsync(orgId, ct);
|
||||||
|
if (!summary.AlreadySeeded)
|
||||||
|
{
|
||||||
|
_log.LogInformation("QuickLoginDemoSeeder: каталог демо наполнен — товаров {Products}, продаж {Sales}",
|
||||||
|
summary.Products, summary.Sales);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning(ex, "QuickLoginDemoSeeder: демо-каталог не наполнился " +
|
||||||
|
"(это ОК — создать его можно из UI через POST /api/admin/seed-demo после логина Admin'ом).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureDemoUsersAsync(Guid orgId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<User>>();
|
||||||
|
|
||||||
|
var adminRole = await db.EmployeeRoles.IgnoreQueryFilters()
|
||||||
|
.FirstAsync(r => r.OrganizationId == orgId && r.IsSystem && r.Name == "Администратор", ct);
|
||||||
|
var cashierRole = await db.EmployeeRoles.IgnoreQueryFilters()
|
||||||
|
.FirstAsync(r => r.OrganizationId == orgId && r.IsSystem && r.Name == "Кассир", ct);
|
||||||
|
var keeperRole = await db.EmployeeRoles.IgnoreQueryFilters()
|
||||||
|
.FirstAsync(r => r.OrganizationId == orgId && r.IsSystem && r.Name == "Кладовщик", ct);
|
||||||
|
|
||||||
|
await EnsureUserAsync(db, userMgr, orgId, DemoAdminEmail, "Демо Админ Магазина",
|
||||||
|
SystemRoles.Admin, adminRole.Id, "Администратор", ct);
|
||||||
|
await EnsureUserAsync(db, userMgr, orgId, DemoCashierEmail, "Демо Кассир",
|
||||||
|
SystemRoles.Cashier, cashierRole.Id, "Кассир", ct);
|
||||||
|
await EnsureUserAsync(db, userMgr, orgId, DemoKeeperEmail, "Демо Кладовщик",
|
||||||
|
SystemRoles.Storekeeper, keeperRole.Id, "Кладовщик", ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task EnsureUserAsync(
|
||||||
|
AppDbContext db, UserManager<User> userMgr,
|
||||||
|
Guid orgId, string email, string fullName,
|
||||||
|
string identityRole, Guid employeeRoleId, string position,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var user = await userMgr.FindByEmailAsync(email);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
user = new User
|
||||||
|
{
|
||||||
|
UserName = email,
|
||||||
|
Email = email,
|
||||||
|
EmailConfirmed = true,
|
||||||
|
FullName = fullName,
|
||||||
|
OrganizationId = orgId,
|
||||||
|
IsActive = true,
|
||||||
|
};
|
||||||
|
var r = await userMgr.CreateAsync(user, "Admin12345!");
|
||||||
|
if (!r.Succeeded) return;
|
||||||
|
await userMgr.AddToRoleAsync(user, identityRole);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Поддерживаем IsActive=true, OrganizationId, роль — чтобы если
|
||||||
|
// кто-то их вручную потрогал, при рестарте всё вернулось в норму.
|
||||||
|
var dirty = false;
|
||||||
|
if (!user.IsActive) { user.IsActive = true; dirty = true; }
|
||||||
|
if (user.OrganizationId != orgId) { user.OrganizationId = orgId; dirty = true; }
|
||||||
|
if (dirty) await userMgr.UpdateAsync(user);
|
||||||
|
|
||||||
|
var roles = await userMgr.GetRolesAsync(user);
|
||||||
|
if (!roles.Contains(identityRole))
|
||||||
|
await userMgr.AddToRoleAsync(user, identityRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasEmployee = await db.Employees.IgnoreQueryFilters()
|
||||||
|
.AnyAsync(e => e.OrganizationId == orgId && e.UserId == user.Id, ct);
|
||||||
|
if (!hasEmployee)
|
||||||
|
{
|
||||||
|
db.Employees.Add(new Employee
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
UserId = user.Id,
|
||||||
|
LastName = fullName.Split(' ').Last(),
|
||||||
|
FirstName = fullName.Split(' ').First(),
|
||||||
|
Position = position,
|
||||||
|
Email = email,
|
||||||
|
RoleId = employeeRoleId,
|
||||||
|
IsActive = true,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,7 @@ import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||||
import { RoleGuard } from '@/components/RoleGuard'
|
import { RoleGuard } from '@/components/RoleGuard'
|
||||||
import { AdvancedModeGuard } from '@/components/AdvancedModeGuard'
|
import { AdvancedModeGuard } from '@/components/AdvancedModeGuard'
|
||||||
import { Toaster } from '@/components/Toaster'
|
import { Toaster } from '@/components/Toaster'
|
||||||
|
import { StageBanner } from '@/components/StageBanner'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import { FormSkeleton } from '@/components/Skeleton'
|
import { FormSkeleton } from '@/components/Skeleton'
|
||||||
|
|
||||||
|
|
@ -117,6 +118,7 @@ export default function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
<StageBanner />
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|
|
||||||
122
src/food-market.web/src/components/QuickLoginBlock.tsx
Normal file
122
src/food-market.web/src/components/QuickLoginBlock.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
|
||||||
|
/// Карточка демо-аккаунта с полей, которые отдаёт /api/dev/quick-login/accounts.
|
||||||
|
export interface QuickLoginAccount {
|
||||||
|
email: string
|
||||||
|
fullName: string
|
||||||
|
role: string | null
|
||||||
|
organizationName: string | null
|
||||||
|
isSuperAdmin: boolean
|
||||||
|
isDemo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Вызывается когда пользователь кликнул по карточке. Email → затем
|
||||||
|
* LoginPage сам делает login(email, '123456'). */
|
||||||
|
onPick(email: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Блок «Быстрый вход (тестовый стенд)» над формой логина. Показывается
|
||||||
|
* ТОЛЬКО когда /api/dev/quick-login/accounts вернул 200. На проде этот
|
||||||
|
* эндпоинт отдаёт 404 — компонент после первого запроса рендерит null,
|
||||||
|
* ничего не светится.
|
||||||
|
*/
|
||||||
|
export function QuickLoginBlock({ onPick }: Props) {
|
||||||
|
const [accounts, setAccounts] = useState<QuickLoginAccount[] | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
api
|
||||||
|
.get<QuickLoginAccount[]>('/api/dev/quick-login/accounts')
|
||||||
|
.then((r) => { if (!cancelled) setAccounts(r.data) })
|
||||||
|
.catch(() => { if (!cancelled) setAccounts(null) })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false) })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Отдельный запрос при изменении строки поиска — чтобы серверный ?q=
|
||||||
|
// работал и всегда отражал серверные ограничения (топ-50, порядок).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accounts) return // не инициализировано — не дёргаемся
|
||||||
|
if (q === '') return // первая загрузка уже без ?q
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
api.get<QuickLoginAccount[]>(`/api/dev/quick-login/accounts?q=${encodeURIComponent(q)}`)
|
||||||
|
.then((r) => setAccounts(r.data))
|
||||||
|
.catch(() => { /* молча — не ломаем страницу */ })
|
||||||
|
}, 200)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
// Намеренно не включаем accounts в deps — он меняется из этого же эффекта.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [q])
|
||||||
|
|
||||||
|
if (loading) return null
|
||||||
|
if (!accounts) return null // 404 или ошибка — на проде ничего не рендерим
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="quick-login-block"
|
||||||
|
className="w-full max-w-md bg-amber-50 dark:bg-amber-950/40 border border-amber-300 dark:border-amber-800 rounded-xl shadow p-5 space-y-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-sm text-amber-900 dark:text-amber-200">
|
||||||
|
Быстрый вход (тестовый стенд)
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-amber-800/80 dark:text-amber-200/70">
|
||||||
|
Кликните по аккаунту — пароль подставится автоматически.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Поиск: email, ФИО, роль, магазин"
|
||||||
|
className="w-full rounded-md border border-amber-300 dark:border-amber-700 bg-white dark:bg-amber-950/60 px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{accounts.length === 0 && (
|
||||||
|
<p className="text-xs text-amber-800/80 dark:text-amber-200/70">
|
||||||
|
Ничего не нашлось. Попробуйте очистить фильтр.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="max-h-80 overflow-y-auto space-y-1.5 -mx-1 px-1">
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.email}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick(a.email)}
|
||||||
|
data-testid={`quick-login-pick-${a.email}`}
|
||||||
|
className="w-full text-left rounded-md bg-white dark:bg-amber-950/70 hover:bg-amber-100 dark:hover:bg-amber-900 border border-amber-200 dark:border-amber-800 px-3 py-2 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||||
|
<span>{a.fullName}</span>
|
||||||
|
{a.isSuperAdmin && (
|
||||||
|
<span className="text-[10px] uppercase font-semibold text-purple-700 bg-purple-100 dark:bg-purple-950/70 dark:text-purple-200 rounded px-1.5 py-0.5">
|
||||||
|
SuperAdmin
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{a.isDemo && !a.isSuperAdmin && (
|
||||||
|
<span className="text-[10px] uppercase font-semibold text-emerald-700 bg-emerald-100 dark:bg-emerald-950/60 dark:text-emerald-200 rounded px-1.5 py-0.5">
|
||||||
|
Демо
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-600 dark:text-slate-400 truncate">
|
||||||
|
{a.email}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{[a.role, a.organizationName].filter(Boolean).join(' · ') || '—'}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
src/food-market.web/src/components/StageBanner.tsx
Normal file
74
src/food-market.web/src/components/StageBanner.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Жёлтая полоса сверху «ТЕСТОВЫЙ СТЕНД — не вводите реальные данные».
|
||||||
|
* Рендерится ТОЛЬКО когда /api/dev/quick-login/accounts отвечает 200
|
||||||
|
* (стенд). В проде эндпоинт 404 → компонент возвращает null, пустое
|
||||||
|
* место сверху не занимается.
|
||||||
|
*
|
||||||
|
* Решение «показывать / скрывать» делается один раз при первом монтировании
|
||||||
|
* (на уровне App.tsx) и кешируется в sessionStorage, чтобы не делать запрос
|
||||||
|
* на каждой навигации. Ключ очищается при закрытии вкладки.
|
||||||
|
*/
|
||||||
|
const CACHE_KEY = 'fm.stage-flag' // "stage" | "prod"
|
||||||
|
|
||||||
|
export function StageBanner() {
|
||||||
|
const [state, setState] = useState<'stage' | 'prod' | 'loading'>(() => {
|
||||||
|
const cached = typeof window !== 'undefined' ? sessionStorage.getItem(CACHE_KEY) : null
|
||||||
|
if (cached === 'stage') return 'stage'
|
||||||
|
if (cached === 'prod') return 'prod'
|
||||||
|
return 'loading'
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== 'loading') return
|
||||||
|
let cancelled = false
|
||||||
|
api
|
||||||
|
.get('/api/dev/quick-login/accounts')
|
||||||
|
.then(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
setState('stage')
|
||||||
|
sessionStorage.setItem(CACHE_KEY, 'stage')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
setState('prod')
|
||||||
|
sessionStorage.setItem(CACHE_KEY, 'prod')
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
// На стенде добавляем <meta name="robots" content="noindex,nofollow"> —
|
||||||
|
// не хотим чтобы Google индексировал test.admin.food-market.kz. Инъектим
|
||||||
|
// динамически (а не в index.html), чтобы один билд ходил и в прод (там
|
||||||
|
// стенда нет и meta не добавляется).
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== 'stage') return
|
||||||
|
const existing = document.querySelector<HTMLMetaElement>('meta[name="robots"]')
|
||||||
|
if (existing) return // уже есть — не ломаем
|
||||||
|
const meta = document.createElement('meta')
|
||||||
|
meta.name = 'robots'
|
||||||
|
meta.content = 'noindex,nofollow'
|
||||||
|
meta.setAttribute('data-origin', 'stage-banner')
|
||||||
|
document.head.appendChild(meta)
|
||||||
|
return () => {
|
||||||
|
// при переходе prod ↔ stage в рамках одной SPA-сессии не нужно
|
||||||
|
// держать meta — но на самом деле это не произойдёт (мы кешируем
|
||||||
|
// решение). На всякий случай очищаем, если флаг улетел в prod.
|
||||||
|
if (meta.parentNode) meta.parentNode.removeChild(meta)
|
||||||
|
}
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
if (state !== 'stage') return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-testid="stage-banner"
|
||||||
|
className="w-full bg-amber-400 text-amber-950 font-semibold text-xs sm:text-sm text-center py-1 shadow-sm"
|
||||||
|
>
|
||||||
|
⚠️ ТЕСТОВЫЙ СТЕНД · не вводите реальные пароли и ОФД-токены
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,14 @@ import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { login } from '@/lib/auth'
|
import { login } from '@/lib/auth'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { Logo } from '@/components/Logo'
|
import { Logo } from '@/components/Logo'
|
||||||
|
import { QuickLoginBlock } from '@/components/QuickLoginBlock'
|
||||||
import { validateEmail, messages } from '@/lib/validation'
|
import { validateEmail, messages } from '@/lib/validation'
|
||||||
|
|
||||||
|
/** Мастер-пароль «быстрого входа» — должен совпадать с server-side
|
||||||
|
* QuickLoginOptions.MasterPassword. Используется только на стенде;
|
||||||
|
* в проде блок быстрого входа не показывается, поэтому значение не утекает. */
|
||||||
|
const QUICK_LOGIN_MASTER_PASSWORD = '123456'
|
||||||
|
|
||||||
interface MeResp { roles: string[] }
|
interface MeResp { roles: string[] }
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
|
|
@ -45,8 +51,36 @@ export function LoginPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Клик по карточке «быстрого входа»: подставляем email + мастер-пароль
|
||||||
|
* и сразу шлём /connect/token. На проде этот путь недоступен — блок
|
||||||
|
* QuickLoginBlock рендерится только после 200 от GET /accounts, которого
|
||||||
|
* в проде нет (404). */
|
||||||
|
async function handleQuickLogin(pickedEmail: string) {
|
||||||
|
setEmail(pickedEmail)
|
||||||
|
setPassword(QUICK_LOGIN_MASTER_PASSWORD)
|
||||||
|
setEmailErr(null); setPasswordErr(null)
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await login(pickedEmail, QUICK_LOGIN_MASTER_PASSWORD)
|
||||||
|
let target = from
|
||||||
|
try {
|
||||||
|
const me = (await api.get<MeResp>('/api/me')).data
|
||||||
|
if (me.roles?.includes('SuperAdmin') && (from === '/' || from === '/dashboard')) {
|
||||||
|
target = '/super-admin'
|
||||||
|
}
|
||||||
|
} catch { /* fallback на from */ }
|
||||||
|
navigate(target, { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Ошибка входа')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
|
<div className="min-h-screen flex flex-col items-center justify-center bg-slate-50 dark:bg-slate-900 p-4 gap-4">
|
||||||
|
<QuickLoginBlock onPick={handleQuickLogin} />
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
noValidate
|
noValidate
|
||||||
|
|
|
||||||
230
tests/e2e/scenarios/stage-quick-login.steps.ts
Normal file
230
tests/e2e/scenarios/stage-quick-login.steps.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
})
|
||||||
|
// Главный инвариант безопасности: прод НЕ отдаёт quick-login endpoint.
|
||||||
|
// Ожидаемый код — 404. 502/503/504 (прод-backend недоступен) допускается
|
||||||
|
// как «тоже не отдаёт», но помечается как gap, чтобы прод-outage было
|
||||||
|
// видно в отчёте.
|
||||||
|
const bodyText = typeof res.data === 'string' ? res.data : JSON.stringify(res.data).slice(0, 200)
|
||||||
|
const looksLikeAccounts = /isSuperAdmin|organizationName/i.test(bodyText)
|
||||||
|
const safe = !looksLikeAccounts && res.status !== 200
|
||||||
|
check(step, {
|
||||||
|
kind: 'api',
|
||||||
|
description: 'Прод admin.food-market.kz /api/dev/quick-login/accounts не отдаёт список',
|
||||||
|
ok: safe,
|
||||||
|
detail: `status=${res.status}` + (looksLikeAccounts ? ' — тело похоже на список аккаунтов!' : ''),
|
||||||
|
})
|
||||||
|
if (!safe) {
|
||||||
|
report.bug({
|
||||||
|
step: 'ql03',
|
||||||
|
severity: 'critical',
|
||||||
|
title: 'ПРОД отдал список аккаунтов — это дыра',
|
||||||
|
detail: `admin.food-market.kz endpoint возвращает ${res.status} с подозрительным телом. Срочно откатить и проверить env.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (res.status >= 500) {
|
||||||
|
report.gap(
|
||||||
|
`Прод-backend admin.food-market.kz отвечает ${res.status} — это пре-существующий outage ` +
|
||||||
|
`(container food-market-api stopped ~2 месяца). Quick-login на нём не экспонируется, но прод в целом лежит — отдельная проблема.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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