food-market/tests/food-market.IntegrationTests/QuickLoginTests.cs
nns e1718ec972
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Public / Build + push Public (test + latest) (push) Waiting to run
Docker Public / Deploy :test → test.food-market.kz (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
feat(s30): sentinel pwd для SPA — реальный мастер-пароль только в server-env
Координатор отротировал мастер-пароль стенда: теперь значение живёт в
~/.fm-watchdog/stage-shared-password на dev-vm (не коммитится) и в
QUICK_LOGIN_MASTER_PASSWORD в stage-env на prod-vm. Старый дефолт '123456'
на обоих сторонах убран — он мог вводить в заблуждение и был записан в
бандл.

Клиент-серверный контракт:
- Server (AuthorizationController.IsMasterPassword) принимает ДВА пароля
  как мастер-ключ: (a) QuickLoginOptions.MasterPassword (константное
  сравнение, FixedTimeEquals); (b) публичный sentinel
  QuickLoginOptions.SentinelPassword = '__QUICK_LOGIN_PWLESS__'. Оба
  гейтованы EffectivelyEnabled — вне стенда отбиваются как любой чужой
  пароль.
- SPA (LoginPage.handleQuickLogin) при клике карточки шлёт sentinel в
  поле password. Реальный пароль в JS-бандл больше не попадает; ротация
  секрета больше не требует rebuild'а веба.
- Форму мы НЕ наполняем sentinel'ом — поле password остаётся пустым,
  чтобы ни одно значение не светилось в DOM.

Default QuickLoginOptions.MasterPassword = "" (пустая строка не
матчится в IsMasterPassword). deploy/docker-compose.yml дефолт
QUICK_LOGIN_MASTER_PASSWORD тоже пустой.

Тесты:
- integration (3/3 ok) — добавлены sentinel-кейсы: пропускает активного
  при Enabled=true, отвергается при Enabled=false. Admin12345!-санити-
  логин в Disabled-тесте перенесён вперёд, чтобы снять lockout-счётчик
  от соседей по коллекции (Identity lockoutOnFailure=true).
- e2e stage-quick-login (4/4 ok) — ql02 шлёт sentinel вместо '123456'
  (симулирует реальный SPA flow).

docs/quick-login.md:
- без упоминания реального пароля; хранилище — ~/.fm-watchdog/
  stage-shared-password; ротация-рецепт через python читает файл
  в-процессе (не экспонирует значение в ps/логи).
- раздел «Доступ к стенду» — host-nginx basic-auth для внешнего
  интернета, 192.168.1.x бьёт без неё.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 21:02:36 +05:00

213 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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&lt;T&gt;.
/// 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);
// Сначала sanity-check с правильным паролем — он заодно сбрасывает
// Identity-lockout counter admin@food-market.local, который могли
// накрутить соседи по коллекции тестов (CheckPasswordSignIn с
// lockoutOnFailure=true увеличивает счётчик на каждый fail).
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);
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");
// Sentinel при выключенном флаге — тоже не пропускается.
var sentinel = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "password",
["username"] = "admin@food-market.local",
["password"] = QuickLoginOptions.SentinelPassword,
["client_id"] = "food-market-web",
["scope"] = "openid profile email roles api",
}));
sentinel.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[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);
// Sentinel (SPA click-to-login) тоже пускает активного — без
// того, чтобы реальный мастер-пароль ехал в теле запроса.
var tokenSentinel = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["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<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;
}
}
}