using System.Net; using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; using foodmarket.Api.Infrastructure; using foodmarket.Infrastructure.Identity; using foodmarket.IntegrationTests.Support; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Xunit; namespace foodmarket.IntegrationTests; /// Sprint 30 — тесты «быстрого входа» на стенде. /// /// Проверки: /// /// unit-матрица : /// false/пустой Issuer/прод-хост → off; стенд → on; /// default ApiFactory (Enabled=false): /accounts → 404 и мастер- /// пароль отвергается; /// включаем Enabled в runtime (мутируем IOptions.Value) + /// подмешиваем stage-Issuer в IConfiguration: мастер-пароль /// пускает активного и не пускает деактивированного; /accounts → 200. /// Возвращаем состояние обратно в finally. /// /// /// Про runtime-мутацию. ASP.NET WebApplicationFactory. /// WithWebHostBuilder на уже-построенной parent-factory даёт /// ObjectDisposedException — переконфигурировать «на лету» нельзя. /// Поэтому мы меняем QuickLoginOptions через IOptions<T>. /// Value (это sync-ссылка на singleton-экземпляр — изменения видны /// всем потребителям, включая controller). OpenIddict:Issuer /// IConfiguration — провайдер (MemoryConfigurationProvider) /// позволяет менять значение после Build через Reload. [Collection(ApiCollection.Name)] public class QuickLoginTests { private readonly ApiFactory _factory; public QuickLoginTests(ApiFactory factory) => _factory = factory; private const string Master = "123456"; [Fact] public void Options_effective_matrix() { var disabled = new QuickLoginOptions { Enabled = false }; disabled.EffectivelyEnabled("https://test.admin.food-market.kz/", out _).Should().BeFalse(); var noIssuer = new QuickLoginOptions { Enabled = true }; noIssuer.EffectivelyEnabled(null, out _).Should().BeFalse(); noIssuer.EffectivelyEnabled("", out _).Should().BeFalse(); var prodIssuer = new QuickLoginOptions { Enabled = true }; prodIssuer.EffectivelyEnabled("https://admin.food-market.kz/", out _).Should().BeFalse(); // Любой поддомен — это стенд prodIssuer.EffectivelyEnabled("https://test.admin.food-market.kz/", out _).Should().BeTrue(); // Non-URL в конфиге — тоже off (не рискуем). new QuickLoginOptions { Enabled = true } .EffectivelyEnabled("это-не-url", out _).Should().BeFalse(); } [Fact] public async Task Disabled_returns_404_and_rejects_master_password() { var http = _factory.CreateClient(); var get = await http.GetAsync("/api/dev/quick-login/accounts"); get.StatusCode.Should().Be(HttpStatusCode.NotFound); // Сначала 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", ["username"] = "admin@food-market.local", ["password"] = Master, ["client_id"] = "food-market-web", ["scope"] = "openid profile email roles api offline_access", })); token.StatusCode.Should().Be(HttpStatusCode.BadRequest); var body = await token.Content.ReadFromJsonAsync(); body.GetProperty("error").GetString().Should().Be("invalid_grant"); // Sentinel при выключенном флаге — тоже не пропускается. var sentinel = 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", })); 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>().Value; var cfg = _factory.Services.GetRequiredService(); var prevEnabled = opts.Enabled; var prevPwd = opts.MasterPassword; var prevIssuer = cfg["OpenIddict:Issuer"]; try { opts.Enabled = true; opts.MasterPassword = Master; cfg["OpenIddict:Issuer"] = "https://test.admin.food-market.kz/"; var http = _factory.CreateClient(); // /accounts → 200 + SuperAdmin в списке. var list = await http.GetAsync("/api/dev/quick-login/accounts"); list.StatusCode.Should().Be(HttpStatusCode.OK); var items = await list.Content.ReadFromJsonAsync(); items.ValueKind.Should().Be(JsonValueKind.Array); items.EnumerateArray() .Any(x => x.GetProperty("email").GetString() == "admin@food-market.local" && x.GetProperty("isSuperAdmin").GetBoolean()) .Should().BeTrue(); // Master-пароль пускает активного SuperAdmin'а. var tokenOk = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "password", ["username"] = "admin@food-market.local", ["password"] = Master, ["client_id"] = "food-market-web", ["scope"] = "openid profile email roles api", })); tokenOk.StatusCode.Should().Be(HttpStatusCode.OK); // 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>(); var email = $"ql-inactive-{Guid.NewGuid():N}@food-market.local"; var inactive = new User { UserName = email, Email = email, EmailConfirmed = true, FullName = "Inactive", IsActive = false, OrganizationId = null, }; (await mgr.CreateAsync(inactive, "Passw0rd!")).Succeeded.Should().BeTrue(); var reject = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "password", ["username"] = email, ["password"] = Master, ["client_id"] = "food-market-web", ["scope"] = "openid profile email roles api", })); reject.StatusCode.Should().Be(HttpStatusCode.BadRequest); // Прод-хост в Issuer — safety-override должен выключить всё, даже // при Enabled=true. cfg["OpenIddict:Issuer"] = "https://admin.food-market.kz/"; var prodList = await http.GetAsync("/api/dev/quick-login/accounts"); prodList.StatusCode.Should().Be(HttpStatusCode.NotFound); var prodToken = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "password", ["username"] = "admin@food-market.local", ["password"] = Master, ["client_id"] = "food-market-web", ["scope"] = "openid profile email roles api", })); prodToken.StatusCode.Should().Be(HttpStatusCode.BadRequest); } finally { opts.Enabled = prevEnabled; opts.MasterPassword = prevPwd; cfg["OpenIddict:Issuer"] = prevIssuer; } } }