diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index d670f4c..e90d4ea 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -40,12 +40,17 @@ services:
RateLimiting__SignupPerIpPerHour: ${RATE_SIGNUP_HOUR:-3}
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).
+ # На stage включается через QUICK_LOGIN_ENABLED=true в .env.
+ # QUICK_LOGIN_MASTER_PASSWORD задаётся секрет-паролем стенда (значение
+ # хранится вне репозитория — на dev-vm в ~/.fm-watchdog/stage-shared-
+ # password). Если переменная пустая — master-password-режим отключён
+ # (IsMasterPassword отбивает пустую строку). SPA отправляет публичный
+ # sentinel '__QUICK_LOGIN_PWLESS__' — реальный пароль в JS не попадает.
+ # 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}
+ QuickLogin__MasterPassword: ${QUICK_LOGIN_MASTER_PASSWORD:-}
# Host port mapping: pick free ports on existing stage server (80/443 taken by
# legacy nginx, 5000/5002/5005 taken by legacy .NET apps).
ports:
diff --git a/docs/quick-login-progress.md b/docs/quick-login-progress.md
index b533833..0422733 100644
--- a/docs/quick-login-progress.md
+++ b/docs/quick-login-progress.md
@@ -1,8 +1,10 @@
# Quick Login — прогресс
-Задача владельца: на `test.admin.food-market.kz` не набирать логин/пароль руками.
-Выбрать аккаунт из списка, пароль у всех один — `123456`. На `admin.food-market.kz`
-этого нет никогда (несколько независимых предохранителей).
+Задача владельца: на `test.admin.food-market.kz` не набирать логин/пароль
+руками. Выбрать аккаунт из списка — ты внутри в один клик. У всех аккаунтов
+один shared-пароль; его значение хранится в `~/.fm-watchdog/stage-shared-
+password` на dev-vm (не коммитится). На `admin.food-market.kz` быстрого
+входа нет никогда (несколько независимых предохранителей).
Координатор отдельно проверил: на стейдже нет реальных секретов (МойСклад
токенов нет, ОФД-ключей нет, Telegram не привязан, SMTP пустой) — потому
@@ -11,10 +13,12 @@
## Чек-лист — все 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.
+ `QuickLogin:MasterPassword` (default пустой; реальное значение — только
+ в stage-env, читается из `~/.fm-watchdog/stage-shared-password`).
+ Включается только через env-переменные `QuickLogin__Enabled=true` в
+ stage-compose. Жёсткий предохранитель: если `OpenIddict:Issuer`
+ указывает на admin.food-market.kz — быстрый вход принудительно
+ выключен и в лог Warning.
Commit: `1f70d90`.
- [x] 2. **Мастер-пароль** — password-grant: `QuickLogin:MasterPassword`
работает как второй вход рядом со своим паролем. Неактивные/без-org
@@ -76,8 +80,15 @@ market@email.com role=Администратор org
```
```
-curl -X POST .../connect/token password=123456 username=demo-cashier@...
+# SPA-поток: шлём sentinel вместо реального пароля
+curl -X POST .../connect/token password=__QUICK_LOGIN_PWLESS__ username=demo-cashier@...
→ 200 + access_token; /api/me → role=Cashier
+
+# API-поток: шлём реальный пароль из файла (без эха значения)
+curl -X POST .../connect/token \
+ --data-urlencode "password@/home/nns/.fm-watchdog/stage-shared-password" \
+ --data-urlencode "username=demo-cashier@food-market.local" ...
+→ 200 + access_token
```
```
@@ -143,4 +154,15 @@ cd ~/food-market-stage/deploy && docker compose -p food-market-stage up -d --for
```
После рестарта `EffectivelyEnabled=false`, endpoint снова 404, master-password
-не работает, UI-блок исчезает. См. `docs/quick-login.md` для деталей.
+и sentinel не работают, UI-блок исчезает. См. `docs/quick-login.md` для деталей.
+
+## История
+
+- **2026-10-04 (Sprint 30, часть 1):** первоначальный ввод на тестовый стенд.
+- **2026-10-04 (часть 2, координатор):** реальный пароль вынесен в
+ `~/.fm-watchdog/stage-shared-password` (не коммитится); SPA перешла на
+ публичный sentinel `__QUICK_LOGIN_PWLESS__` → мастер-пароль больше не
+ попадает в JS-бандл; `test.admin.food-market.kz` для внешнего интернета
+ закрыт basic-auth (пароль = тот же), внутренняя сеть `192.168.1.x`
+ бьёт без basic-auth. Default `MasterPassword=""`, sentinel-поведение
+ в `AuthorizationController.IsMasterPassword`.
diff --git a/docs/quick-login.md b/docs/quick-login.md
index 0e48dd7..6fc6539 100644
--- a/docs/quick-login.md
+++ b/docs/quick-login.md
@@ -1,12 +1,23 @@
# Quick Login — быстрый вход на тестовом стенде
-**Статус:** включено на `https://test.admin.food-market.kz` (стенд).
+**Статус:** включено на `https://test.admin.food-market.kz` (стенд).
**Прод:** `https://admin.food-market.kz` — выключено, попытки активации
принудительно блокируются safety-override'ом в коде.
Это dev-удобство: на стенде не нужно каждый раз вводить логин+пароль.
Выбираешь карточку аккаунта на `/login`, клик — ты внутри.
+## Доступ к стенду
+
+Стенд `test.admin.food-market.kz` для публичного интернета закрыт
+HTTP basic-auth на host-уровне nginx (логин `food-market`). Из внутренней
+сети `192.168.1.x` (dev-vm, prod-vm) basic-auth не требуется — e2e,
+quality-watchdog и обычная разработка работают как раньше.
+
+Пароль basic-auth = пароль quick-login (чтобы не плодить секреты). Хранится
+на dev-vm в файле `~/.fm-watchdog/stage-shared-password` (mode 600, не
+коммитится).
+
## Как это работает
### Server-side
@@ -15,25 +26,33 @@
```yaml
QuickLogin:
- Enabled: false # default — выключено
- MasterPassword: "123456"
+ Enabled: false # default — выключено
+ MasterPassword: "" # default пустой; реальный секрет — ТОЛЬКО в stage-env
```
-Включается через env в stage-compose:
+На стенде env в `~/food-market-stage/deploy/.env`:
```
QUICK_LOGIN_ENABLED=true
-QUICK_LOGIN_MASTER_PASSWORD=123456
+QUICK_LOGIN_MASTER_PASSWORD=<значение из ~/.fm-watchdog/stage-shared-password>
```
-Логика работает в двух местах:
+Пароль прокидывается в контейнер build'ом из файла и в репозиторий не
+попадает. Прод-compose этой переменной не содержит, флаг остаётся false.
+
+Логика срабатывает в двух местах:
- **`POST /connect/token` (grant=password)** — при `EffectivelyEnabled=true`
- пароль `MasterPassword` принимается у ЛЮБОГО активного пользователя наравне
- с его собственным паролем. Константное сравнение (`CryptographicOperations.
- FixedTimeEquals`), чтобы не светить длину. Деактивированные (`IsActive=
- false`) не пускаются ни при каких условиях. `/connect/token` всё так же
- гонит через rate-limit, 2FA и org-check (архивная org отшивает).
+ принимает ДВА варианта пароля наравне с собственным паролем юзера:
+ 1. **MasterPassword** (значение env'а) — для ручного/скриптового
+ входа, когда у тебя есть сам пароль. Константное сравнение
+ (`FixedTimeEquals`).
+ 2. **Sentinel** — публичная строка `__QUICK_LOGIN_PWLESS__`, которую
+ шлёт SPA при клике по карточке. Сервер трактует её как «пустить
+ этого юзера». Это позволяет SPA не хранить реальный пароль в
+ JS-бандле.
+ И тот и другой путь гейтованы `EffectivelyEnabled` — вне стенда оба
+ отфутболиваются как любой другой чужой пароль.
- **`GET /api/dev/quick-login/accounts`** — анонимный эндпоинт, который
возвращает «человеческие» аккаунты для карточек: SuperAdmin → demo-trio →
@@ -46,14 +65,17 @@ QUICK_LOGIN_MASTER_PASSWORD=123456
- `` над формой логина сам опрашивает `/api/dev/quick-
login/accounts`. 200 → рендерит жёлтый блок с карточками + поиск. 404 →
- возвращает `null`, никакого UI не рисует. Клик по карточке → login с
- `MasterPassword`.
+ возвращает `null`, никакого UI не рисует.
+- Клик по карточке → `POST /connect/token` с паролем **sentinel**
+ `__QUICK_LOGIN_PWLESS__`. Реальный мастер-пароль в бандл не попадает —
+ ротация пароля в env не требует rebuild'а веба.
- `` жёлтой полосой сверху всех страниц, когда эндпоинт
доступен. При том же сигнале динамически добавляет `` — чтобы Google не индексировал стенд.
+ content="noindex,nofollow">`.
Один web-билд используется и в проде, и на стенде — секрет на проде не
-утекает: `/api/dev/quick-login/accounts` там 404, значит блока нет.
+утекает: `/api/dev/quick-login/accounts` там 404, значит блока нет, а
+sentinel сервером всё равно отвергается.
## Безопасность — предохранители
@@ -61,50 +83,67 @@ QUICK_LOGIN_MASTER_PASSWORD=123456
включить. В прод-compose её нет и не должно быть.
2. **Issuer safety-override.** В `QuickLoginOptions.EffectivelyEnabled`:
если `OpenIddict:Issuer` указывает на `admin.food-market.kz` (ТОЧНО,
- без поддоменов), быстрый вход **принудительно выключен**. Это защищает
- от ошибки конфигурации — поставили `QUICK_LOGIN_ENABLED=true` в
- прод-env по ошибке, но Issuer остался прод-овым → ничего не включилось,
- в лог Warning.
+ без поддоменов), быстрый вход **принудительно выключен**. Защита от
+ ошибки конфигурации — поставили `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 логирует одно решение на старте:
+ окружении.
+4. **Пустой MasterPassword отбивает мастер-пароль.** Пустая строка не
+ матчится в `IsMasterPassword` (отбивается до FixedTimeEquals). Это
+ защищает от случайно разворачиваемого стенда без секрета.
+5. **Rate-limit не ослаблен.** Sentinel и master-password идут через ту
+ же защиту, что обычный password-grant.
+6. **Неактивные пользователи не пускаются.** Проверка `IsActive` сверху
+ `/connect/token` — ни sentinel, ни master-password это не обходят.
+7. **Startup Warning.** Program.cs логирует одно решение на старте:
«включён для стенда» или «Enabled=true, но безопасность выключила».
- В логах прод-инстанса всегда видно, что происходит.
+8. **Basic auth на host-nginx.** Внешние запросы к `test.admin.food-
+ market.kz` отбиваются 401 без корректных basic-auth creds. Внутренняя
+ сеть исключена из этого gate'а (чтобы автоматика работала). Этот
+ уровень живёт за пределами репозитория — в gateway nginx на prod-vm.
## Демо-аккаунты
`QuickLoginDemoSeeder` при старте с `EffectivelyEnabled=true` поднимает:
-- Организацию **«Демо-магазин»** (идемпотентно; отдельная от «Demo Market»
- из `DevDataSeeder`, чтобы не ломать e2e).
+- Организацию **«Демо-магазин»** (идемпотентно).
- Полный demo-каталог через `DemoTenantSeeder` (50 товаров / 30 продаж /
- приёмки / контрагенты).
+ приёмки / контрагенты). Если демо-каталог не наполнился (нет HTTP-
+ контекста при startup → `Tenant not set`), юзеры всё равно создаются.
- Трио учёток с ролями:
- `demo-admin@food-market.local` → `Admin`
- `demo-cashier@food-market.local` → `Cashier`
- `demo-keeper@food-market.local` → `Storekeeper`
Собственный пароль у них `Admin12345!` (унаследовано от шаблона), но
-мастер-пароль `123456` тоже работает.
+sentinel и master-password тоже работают.
-## Чистка автотестового мусора
+## Как ротирнуть секрет-пароль
-`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`.
+1. На dev-vm обновить `~/.fm-watchdog/stage-shared-password` (mode 600).
+2. На prod-vm обновить `~/food-market-stage/deploy/.env`:
+ `QUICK_LOGIN_MASTER_PASSWORD=<новое значение>`. Удобно без эха:
+ ```bash
+ # с dev-vm
+ scp ~/.fm-watchdog/stage-shared-password nns@192.168.1.190:/tmp/.ql-pw
+ ssh nns@192.168.1.190 'python3 -c "
+ import pathlib, re
+ pw = pathlib.Path(\"/tmp/.ql-pw\").read_text().strip()
+ env = pathlib.Path(\"/home/nns/food-market-stage/deploy/.env\")
+ env.write_text(re.sub(r\"^QUICK_LOGIN_MASTER_PASSWORD=.*$\",
+ f\"QUICK_LOGIN_MASTER_PASSWORD={pw}\", env.read_text(), flags=re.M))" \
+ && rm /tmp/.ql-pw && grep -c QUICK_LOGIN_MASTER_PASSWORD \
+ ~/food-market-stage/deploy/.env'
+ ```
+3. На prod-vm также обновить basic-auth htpasswd для `test.admin.food-
+ market.kz` в nginx — тот же пароль с юзером `food-market`.
+4. Рестартнуть API: `ssh nns@192.168.1.190 "cd ~/food-market-stage/deploy
+ && docker compose -p food-market-stage up -d --force-recreate api"`.
+5. Веб rebuild **не нужен** — sentinel не меняется.
## Как выключить обратно
-На стенде:
-
```bash
# на prod-vm
nns@192.168.1.190:$ sed -i '/^QUICK_LOGIN_ENABLED=/d; /^QUICK_LOGIN_MASTER_PASSWORD=/d' \
@@ -113,33 +152,39 @@ nns@192.168.1.190:$ cd ~/food-market-stage/deploy && docker compose -p food-mark
```
API перестартует, `EffectivelyEnabled` увидит `Enabled=false`, эндпоинт
-снова 404, master-password не работает. Карточки на `/login` исчезнут —
-`QuickLoginBlock` увидит 404 и вернёт `null`.
+снова 404, sentinel и 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
+# ожидается: 404 (или 502, если прод-API вообще не поднят)
```
-Любой другой код (200/403/500) — проблема.
+Любой `200` — проблема.
## Файлы
| Что | Где |
|---|---|
-| Опции + safety-override | `src/food-market.api/Infrastructure/QuickLoginOptions.cs` |
-| Master-password в token-grant | `src/food-market.api/Controllers/AuthorizationController.cs` |
+| Опции + safety-override + sentinel-константа | `src/food-market.api/Infrastructure/QuickLoginOptions.cs` |
+| Master-password + sentinel в 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` |
+| UI блок (sentinel-flow) | `src/food-market.web/src/pages/LoginPage.tsx` + `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}` |
+| Секрет-пароль (на dev-vm) | `~/.fm-watchdog/stage-shared-password` (вне репо) |
## История
-- **2026-10-04 (Sprint 30):** первоначальный ввод на тестовый стенд.
+- **2026-10-04 (Sprint 30, часть 1):** первоначальный ввод на тестовый стенд.
+- **2026-10-04 (Sprint 30, часть 2):** секрет-пароль вынесен в `~/.fm-
+ watchdog/stage-shared-password` (вне репо); SPA перешла на sentinel —
+ реальный пароль в JS-бандл больше не попадает; стенд закрыт basic-auth
+ на host-nginx для внешнего интернета, внутренние 192.168.1.x бьют без
+ пароля.
diff --git a/src/food-market.api/Controllers/AuthorizationController.cs b/src/food-market.api/Controllers/AuthorizationController.cs
index b9ba834..a3b4e82 100644
--- a/src/food-market.api/Controllers/AuthorizationController.cs
+++ b/src/food-market.api/Controllers/AuthorizationController.cs
@@ -205,12 +205,24 @@ or HttpContextTenantContext.OrganizationClaim
[Parameters.ErrorDescription] = description
});
- /// Константное сравнение пароля с мастер-ключом. Пустой пароль
- /// и пустой мастер-ключ отфутболиваются ДО FixedTimeEquals, чтобы случайно
- /// не пустить любого, кто прислал "" в деконфигурированном окружении.
+ /// «Пропустить этого юзера как по мастер-паролю». Срабатывает в
+ /// двух случаях:
+ ///
+ /// - совпадает с
+ /// (константное
+ /// сравнение, FixedTimeEquals — не светит длину по времени);
+ /// - равен публичному sentinel'у
+ /// . Это позволяет
+ /// SPA реализовать клик-по-карточке без того, чтобы реальный мастер-
+ /// пароль жил в JS-бандле: SPA шлёт известную строку, которую
+ /// сервер трактует «пустить». Безопасность sentinel'а равна
+ /// безопасности самого эндпоинта: оба гейтованы EffectivelyEnabled
+ /// (вне стенда — 400 invalid_grant как с любым чужим паролем).
+ ///
private bool IsMasterPassword(string? supplied)
{
if (string.IsNullOrEmpty(supplied)) return false;
+ if (supplied == QuickLoginOptions.SentinelPassword) return true;
if (string.IsNullOrEmpty(_quickLogin.MasterPassword)) return false;
var a = System.Text.Encoding.UTF8.GetBytes(supplied);
var b = System.Text.Encoding.UTF8.GetBytes(_quickLogin.MasterPassword);
diff --git a/src/food-market.api/Infrastructure/QuickLoginOptions.cs b/src/food-market.api/Infrastructure/QuickLoginOptions.cs
index fab4e88..e02090c 100644
--- a/src/food-market.api/Infrastructure/QuickLoginOptions.cs
+++ b/src/food-market.api/Infrastructure/QuickLoginOptions.cs
@@ -20,8 +20,23 @@ public sealed class QuickLoginOptions
/// Единый пароль-ключ для стенда. При включённом флаге подходит
/// к ЛЮБОМУ активному аккаунту в /connect/token grant=password.
/// Собственный пароль пользователя тоже продолжает работать (чтобы не
- /// ломать e2e, seeded admin и т.д.).
- public string MasterPassword { get; set; } = "123456";
+ /// ломать e2e, seeded admin и т.д.).
+ ///
+ /// Значение задаётся в env QuickLogin__MasterPassword. Если
+ /// переменная не задана — значение пустое и мастер-пароль-режим в
+ /// отбивает любую попытку (пустая строка
+ /// не матчится).
+ public string MasterPassword { get; set; } = "";
+
+ /// Публичный sentinel, который SPA отправляет в поле password
+ /// при клике по карточке быстрого входа. Он ПУБЛИЧЕН (строка в JS-бандле)
+ /// и сам по себе не секрет: сервер при =true
+ /// принимает sentinel как «пустить этого юзера» (эквивалент мастер-пароля)
+ /// без того, чтобы сам мастер-пароль утекал в бандл или в HTTP-ответы.
+ ///
+ /// Защита: вне стенда =false, sentinel
+ /// отбивается обычным не-подошёл-пароль как любой другой чужой пароль.
+ public const string SentinelPassword = "__QUICK_LOGIN_PWLESS__";
/// Эффективный флаг с учётом safety-override по Issuer. Используй
/// везде, где решается «пускать мастер-пароль / отдавать список». Чтение
diff --git a/src/food-market.web/src/pages/LoginPage.tsx b/src/food-market.web/src/pages/LoginPage.tsx
index c44076d..3a17412 100644
--- a/src/food-market.web/src/pages/LoginPage.tsx
+++ b/src/food-market.web/src/pages/LoginPage.tsx
@@ -6,10 +6,14 @@ import { Logo } from '@/components/Logo'
import { QuickLoginBlock } from '@/components/QuickLoginBlock'
import { validateEmail, messages } from '@/lib/validation'
-/** Мастер-пароль «быстрого входа» — должен совпадать с server-side
- * QuickLoginOptions.MasterPassword. Используется только на стенде;
- * в проде блок быстрого входа не показывается, поэтому значение не утекает. */
-const QUICK_LOGIN_MASTER_PASSWORD = '123456'
+/** Публичный sentinel-«пароль» для клика по карточке быстрого входа.
+ * Должен совпадать с server-side QuickLoginOptions.SentinelPassword.
+ * Реальный мастер-пароль живёт ТОЛЬКО в server-env (QuickLogin__
+ * MasterPassword) — в JS-бандл он не попадает, и наш билд не меняется
+ * при ротации пароля. Вне стенда сервер отфутболивает sentinel как
+ * обычный невалидный пароль (блок всё равно не рендерится, т.к.
+ * /api/dev/quick-login/accounts отдаёт 404). */
+const QUICK_LOGIN_SENTINEL = '__QUICK_LOGIN_PWLESS__'
interface MeResp { roles: string[] }
@@ -51,18 +55,19 @@ export function LoginPage() {
}
}
- /** Клик по карточке «быстрого входа»: подставляем email + мастер-пароль
- * и сразу шлём /connect/token. На проде этот путь недоступен — блок
- * QuickLoginBlock рендерится только после 200 от GET /accounts, которого
- * в проде нет (404). */
+ /** Клик по карточке «быстрого входа»: подставляем email + sentinel и
+ * сразу шлём /connect/token. Сервер при EffectivelyEnabled=true
+ * трактует sentinel как мастер-пароль, но сам пароль в бандл не ушёл.
+ * На проде этот путь недоступен — блок QuickLoginBlock рендерится
+ * только после 200 от GET /accounts, которого в проде нет (404). */
async function handleQuickLogin(pickedEmail: string) {
setEmail(pickedEmail)
- setPassword(QUICK_LOGIN_MASTER_PASSWORD)
+ setPassword('') // форму НЕ наполняем — чтобы sentinel не светился в полях
setEmailErr(null); setPasswordErr(null)
setLoading(true)
setError(null)
try {
- await login(pickedEmail, QUICK_LOGIN_MASTER_PASSWORD)
+ await login(pickedEmail, QUICK_LOGIN_SENTINEL)
let target = from
try {
const me = (await api.get('/api/me')).data
diff --git a/tests/e2e/scenarios/stage-quick-login.steps.ts b/tests/e2e/scenarios/stage-quick-login.steps.ts
index 12f8ae7..d62e220 100644
--- a/tests/e2e/scenarios/stage-quick-login.steps.ts
+++ b/tests/e2e/scenarios/stage-quick-login.steps.ts
@@ -76,10 +76,15 @@ export async function ql01_stage_endpoint_returns_200({ ctx, step, report }: Ste
export async function ql02_master_password_logs_in_demo_cashier({ step, report }: StepCtx) {
const api = makeClient()
+ // SPA-эквивалент: шлём sentinel вместо реального мастер-пароля (который
+ // живёт только в server-env, не в бандле). Сервер при EffectivelyEnabled
+ // трактует sentinel как «пустить». Тест реалистичен — именно так SPA
+ // ходит, когда юзер жмёт карточку.
+ const SENTINEL = '__QUICK_LOGIN_PWLESS__'
const body = new URLSearchParams({
grant_type: 'password',
username: 'demo-cashier@food-market.local',
- password: '123456',
+ password: SENTINEL,
client_id: 'food-market-web',
scope: 'openid profile email roles api offline_access',
})
@@ -89,7 +94,7 @@ export async function ql02_master_password_logs_in_demo_cashier({ step, report }
const ok = res.status === 200 && typeof res.data?.access_token === 'string'
check(step, {
kind: 'api',
- description: 'master-пароль "123456" пускает demo-cashier → 200 + access_token',
+ description: 'sentinel пускает demo-cashier → 200 + access_token',
ok,
detail: ok ? 'token получен' : `status=${res.status} data=${asString(res.data).slice(0, 200)}`,
})
@@ -97,8 +102,8 @@ export async function ql02_master_password_logs_in_demo_cashier({ step, report }
report.bug({
step: 'ql02',
severity: 'critical',
- title:'master-пароль не работает для demo-cashier',
- detail: 'QuickLogin:MasterPassword на стенде должен быть "123456", и demo-cashier активен.',
+ title: 'sentinel не пускает demo-cashier',
+ detail: 'QuickLogin:Enabled должен быть true. demo-cashier активен. Проверь, что QuickLoginOptions.SentinelPassword на бэке совпадает с клиентским.',
})
return
}
diff --git a/tests/e2e/scenarios/stage-quick-login.yml b/tests/e2e/scenarios/stage-quick-login.yml
index 6fb2087..0a0cf97 100644
--- a/tests/e2e/scenarios/stage-quick-login.yml
+++ b/tests/e2e/scenarios/stage-quick-login.yml
@@ -18,7 +18,7 @@ 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
+ title: POST /connect/token username=demo-cashier@food-market.local password= → 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
diff --git a/tests/food-market.IntegrationTests/QuickLoginTests.cs b/tests/food-market.IntegrationTests/QuickLoginTests.cs
index 7d68c00..2ae8b5c 100644
--- a/tests/food-market.IntegrationTests/QuickLoginTests.cs
+++ b/tests/food-market.IntegrationTests/QuickLoginTests.cs
@@ -71,6 +71,20 @@ public async Task Disabled_returns_404_and_rejects_master_password()
var get = await http.GetAsync("/api/dev/quick-login/accounts");
get.StatusCode.Should().Be(HttpStatusCode.NotFound);
+ // Сначала sanity-check с правильным паролем — он заодно сбрасывает
+ // Identity-lockout counter admin@food-market.local, который могли
+ // накрутить соседи по коллекции тестов (CheckPasswordSignIn с
+ // lockoutOnFailure=true увеличивает счётчик на каждый fail).
+ var ok = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary
+ {
+ ["grant_type"] = "password",
+ ["username"] = "admin@food-market.local",
+ ["password"] = "Admin12345!",
+ ["client_id"] = "food-market-web",
+ ["scope"] = "openid profile email roles api",
+ }));
+ ok.StatusCode.Should().Be(HttpStatusCode.OK);
+
var token = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary
{
["grant_type"] = "password",
@@ -83,16 +97,16 @@ public async Task Disabled_returns_404_and_rejects_master_password()
var body = await token.Content.ReadFromJsonAsync();
body.GetProperty("error").GetString().Should().Be("invalid_grant");
- // Правильный пароль у admin'а — sanity-check, что backend вообще отвечает OK.
- var ok = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary
+ // Sentinel при выключенном флаге — тоже не пропускается.
+ var sentinel = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary
{
["grant_type"] = "password",
["username"] = "admin@food-market.local",
- ["password"] = "Admin12345!",
+ ["password"] = QuickLoginOptions.SentinelPassword,
["client_id"] = "food-market-web",
["scope"] = "openid profile email roles api",
}));
- ok.StatusCode.Should().Be(HttpStatusCode.OK);
+ sentinel.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
@@ -136,6 +150,18 @@ public async Task Enabled_at_runtime_allows_master_for_active_and_rejects_inacti
}));
tokenOk.StatusCode.Should().Be(HttpStatusCode.OK);
+ // Sentinel (SPA click-to-login) тоже пускает активного — без
+ // того, чтобы реальный мастер-пароль ехал в теле запроса.
+ var tokenSentinel = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary
+ {
+ ["grant_type"] = "password",
+ ["username"] = "admin@food-market.local",
+ ["password"] = QuickLoginOptions.SentinelPassword,
+ ["client_id"] = "food-market-web",
+ ["scope"] = "openid profile email roles api",
+ }));
+ tokenSentinel.StatusCode.Should().Be(HttpStatusCode.OK);
+
// Создаём неактивного и проверяем, что master-пароль его НЕ пускает.
using var scope = _factory.Services.CreateScope();
var mgr = scope.ServiceProvider.GetRequiredService>();