food-market/tests/food-market.IntegrationTests/QuickLoginTests.cs
nns 338877eb49 test(s30): integration 3/3 + e2e stage-quick-login scenario (шаг 8/9)
Integration (QuickLoginTests.cs):
- Options_effective_matrix: Enabled=false → off; пустой/мусорный Issuer → off;
  Issuer=admin.food-market.kz → off (прод); любой другой host (включая
  test.admin.*) → on.
- Disabled_returns_404_and_rejects_master_password: default-factory
  (Enabled=false) /accounts → 404, master-пароль → 400 invalid_grant,
  обычный Admin12345! → 200 (sanity check).
- Enabled_at_runtime_...: мутируем IOptions<QuickLoginOptions>.Value +
  IConfiguration в живом сервис-провайдере (так как WithWebHostBuilder на
  уже-построенной parent-factory даёт ObjectDisposedException). Проверяем:
  /accounts → 200 + SuperAdmin в списке; master-пароль пускает активного
  SuperAdmin'а; НЕ пускает деактивированного; смена Issuer на прод
  переключает всё в safety-off.

Также фикс в QuickLoginOptions: прод-хост теперь СТРОГО 'admin.food-market.kz'
без suffix-match (раньше test.admin.food-market.kz тоже считался прод-ом).

E2E (tests/e2e/scenarios/stage-quick-login.{yml,steps.ts}): 4 шага —
GET /accounts на стенде → 200 + демо-trio; master-пароль пускает
demo-cashier → /api/me role=Cashier; GET на прод → 404; Playwright: клик
по карточке demo-cashier → редирект с /login + нет ссылки /products
в sidebar (меню кассира).

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

187 lines
8.8 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);
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;
}
}
}