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
Координатор отротировал мастер-пароль стенда: теперь значение живёт в ~/.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>
213 lines
10 KiB
C#
213 lines
10 KiB
C#
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<T>.
|
||
/// 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;
|
||
}
|
||
}
|
||
}
|