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;
}
}
}