feat(s30): /api/dev/quick-login/accounts — анонимный список для стенда (шаг 3/9)

Анонимный эндпоинт, который ВИДЕН только когда QuickLogin эффективно
включён (EffectivelyEnabled=true). В проде отдаёт 404, чтобы по наличию
эндпоинта нельзя было вычислить среду.

Фильтрация:
- IsActive=false → не показываем;
- автотестовые (prefix cat31-/visual-/factory-/mt6*/doc4*/auth2*/rep5*/
  rt8*/wiz9*/quality-/i18n*/twofa*/a11y-/mob14-/mob15-/iso*/axdet-/
  smoke-/ratetest-/perf-/probe-/load-/s19-test-/admin-/cashier-/
  keeper-) → не показываем;
- email@e.kz, @test-fm.local — служебные домены diag-probes, тоже скрыты;
- длинный числовой суффикс [0-9]{10,} в email → автотест.

Порядок: SuperAdmin → demo → остальные (ThenBy email для стабильности).
Лимит 50 + поиск ?q по email/full-name/org-name/role (case-insensitive).

Карточка: email, FullName, роль (SuperAdmin / EmployeeRole.Name),
название org, флаги IsSuperAdmin/IsDemo для сортировки/отображения.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-04 15:38:23 +05:00
parent 7647af7527
commit ed5089bac3
2 changed files with 185 additions and 1 deletions

View file

@ -18,7 +18,7 @@
- [x] 2. **Мастер-пароль** — password-grant: `QuickLogin:MasterPassword` - [x] 2. **Мастер-пароль** — password-grant: `QuickLogin:MasterPassword`
работает как второй вход рядом со своим паролем. Неактивные/без-org работает как второй вход рядом со своим паролем. Неактивные/без-org
пользователи — отшиваются как раньше. Rate-limit не ослаблять. пользователи — отшиваются как раньше. Rate-limit не ослаблять.
- [ ] 3. **API списка аккаунтов** — `GET /api/dev/quick-login/accounts` - [x] 3. **API списка аккаунтов** — `GET /api/dev/quick-login/accounts`
(анонимный, 404 если выключен). Порядок: SuperAdmin → демо-* → остальные (анонимный, 404 если выключен). Порядок: SuperAdmin → демо-* → остальные
«человеческие». Autotest-патéрны (cat31-*, visual-*, factory-smoke-*, mt6*, «человеческие». Autotest-патéрны (cat31-*, visual-*, factory-smoke-*, mt6*,
quality-*, i18n*-*, auth2*-*, doc4*-*, rep5*-*, rt8*-*, twofa*-*, wiz9*-*, quality-*, i18n*-*, auth2*-*, doc4*-*, rep5*-*, rt8*-*, twofa*-*, wiz9*-*,

View file

@ -0,0 +1,184 @@
using foodmarket.Api.Infrastructure;
using foodmarket.Infrastructure.Identity;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace foodmarket.Api.Controllers;
/// <summary>Dev-only список аккаунтов для «быстрого входа» на тестовом стенде.
/// Анонимный, но доступен ТОЛЬКО когда <see cref="QuickLoginOptions.EffectivelyEnabled"/>
/// = true. В проде отдаёт 404 — ни отфильтрованных, ни пустых ответов, чтобы
/// по наличию эндпоинта нельзя было вычислить среду.
///
/// Что отдаём: активных «человеческих» пользователей — SuperAdmin, затем
/// demo-* (seeded), затем остальные. Автотестовые (с числовым суффиксом или
/// служебным префиксом) отсеиваются по шаблонам из <see cref="AutoTestPatterns"/>.</summary>
[ApiController]
[AllowAnonymous]
[Route("api/dev/quick-login")]
public class QuickLoginController : ControllerBase
{
private readonly AppDbContext _db;
private readonly UserManager<User> _userManager;
private readonly QuickLoginOptions _opts;
private readonly string? _issuer;
public QuickLoginController(
AppDbContext db,
UserManager<User> userManager,
IOptions<QuickLoginOptions> opts,
IConfiguration cfg)
{
_db = db;
_userManager = userManager;
_opts = opts.Value;
_issuer = cfg["OpenIddict:Issuer"];
}
public record QuickLoginAccount(
string Email,
string FullName,
string? Role,
string? OrganizationName,
bool IsSuperAdmin,
bool IsDemo);
/// <summary>Шаблоны логинов, которые считаем автотестовыми и не показываем
/// в списке. Проверка — case-insensitive, по началу email'а.</summary>
private static readonly string[] AutoTestPatterns =
{
"cat31-", "cat32-", "cat33-", "cat34-", "cat35-",
"visual-", "factory-smoke-", "factory-",
"mt61-", "mt62-", "mt63-",
"doc41-", "doc42-", "doc43-", "doc44-", "doc45-", "doc46-", "doc47-", "doc48-",
"auth21-", "auth22-", "auth23-", "auth24-",
"rep51-", "rep52-", "rep53-", "rep54-",
"rt81-", "rt82-", "rt83-", "rt84-",
"wiz91-", "wiz92-", "wiz93-", "wiz94-", "wiz95-", "wiz-",
"quality-", "i18n71-", "i18n72-", "twofa73-",
"a11y-", "mob14", "mob15", "iso",
"axdet-", "smoke-", "ratetest-", "perf-", "probe-",
"load-sales-", "load-", "s19-test-",
"admin-", "cashier-", "keeper-", // seeded test triples по id
};
/// <summary>Email-домены, которые считаем «служебными» для автотестов.
/// На стенде реальные юзеры создаются только с @food-market.local и
/// @demo-market.local, остальное — автоматика.</summary>
private static readonly string[] AutoTestDomains =
{
"@e.kz", // короткие diagnostic-probes (dn29418@e.kz и т.п.)
"@test-fm.local", // quality-watchdog
};
[HttpGet("accounts")]
public async Task<IActionResult> GetAccounts([FromQuery] string? q, CancellationToken ct = default)
{
if (!_opts.EffectivelyEnabled(_issuer, out _))
{
// 404 — не 403: наличие эндпоинта не должно palятся в проде. По
// шаблону Admin-only эндпоинтов, где отключение = скрытие.
return NotFound();
}
// Активные + с org (SuperAdmin — без org). IsActive=false не пускаем,
// как и в password-grant: быстрый вход для «уволенного» = бессмыслица.
var users = await _db.Users
.IgnoreQueryFilters()
.Where(u => u.IsActive)
.Select(u => new
{
u.Id,
u.UserName,
u.Email,
u.FullName,
u.OrganizationId,
OrgName = u.OrganizationId == null
? null
: _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == u.OrganizationId && !o.IsArchived)
.Select(o => o.Name).FirstOrDefault(),
// Роль в organization — через Employee.RoleId → EmployeeRole.Name.
// Берём первую активную (у юзера обычно одна Employee-запись).
RoleName = _db.Employees.IgnoreQueryFilters()
.Where(e => e.UserId == u.Id && e.IsActive && !e.IsDeleted)
.Select(e => e.Role.Name)
.FirstOrDefault(),
})
.ToListAsync(ct);
// ASP.NET Identity role: SuperAdmin не живёт в Employee, а в AspNetUserRoles.
// Делаем отдельный проход — JOIN через UserManager, не EF-рефлексией.
var superAdminUserIds = (await _userManager.GetUsersInRoleAsync(SystemRoles.SuperAdmin))
.Select(u => u.Id)
.ToHashSet();
var result = users
.Where(u => !IsAutoTest(u.Email ?? u.UserName ?? ""))
// SuperAdmin и demo-* остаются даже без OrgName (SuperAdmin без org'и).
.Select(u =>
{
var isSa = superAdminUserIds.Contains(u.Id);
var isDemo = IsDemoAccount(u.Email ?? u.UserName ?? "");
string? role = isSa ? "SuperAdmin" : (u.RoleName ?? null);
return new QuickLoginAccount(
Email: u.Email ?? u.UserName ?? "",
FullName: string.IsNullOrWhiteSpace(u.FullName) ? (u.Email ?? "") : u.FullName,
Role: role,
OrganizationName: isSa ? "Платформа" : u.OrgName,
IsSuperAdmin: isSa,
IsDemo: isDemo);
})
.Where(a =>
{
if (string.IsNullOrWhiteSpace(q)) return true;
var needle = q.Trim().ToLowerInvariant();
return a.Email.ToLowerInvariant().Contains(needle)
|| (a.FullName ?? "").ToLowerInvariant().Contains(needle)
|| (a.OrganizationName ?? "").ToLowerInvariant().Contains(needle)
|| (a.Role ?? "").ToLowerInvariant().Contains(needle);
})
// Порядок: SuperAdmin → demo → остальные (по email для стабильности).
.OrderByDescending(a => a.IsSuperAdmin)
.ThenByDescending(a => a.IsDemo)
.ThenBy(a => a.Email)
.Take(50)
.ToList();
return Ok(result);
}
/// <summary>Автотестовые юзеры: длинный числовой суффикс (10+ цифр подряд)
/// или email начинается с одного из <see cref="AutoTestPatterns"/>.
/// Исключения — <c>demo-admin@...</c>/<c>demo-cashier@...</c>/<c>demo-keeper@...</c>
/// обрабатываются <see cref="IsDemoAccount"/>, они НЕ matchат ни один из
/// autotest-префиксов (нет "admin-", только "demo-admin@").</summary>
private static bool IsAutoTest(string email)
{
if (string.IsNullOrEmpty(email)) return true;
var lower = email.ToLowerInvariant();
// Длинный числовой суффикс (миллисекундный timestamp 13 цифр, или 10+ цифр
// в середине). Любой email вида что-то-1780849101465@... считаем автотестом.
var m = System.Text.RegularExpressions.Regex.Match(lower, "[0-9]{10,}");
if (m.Success) return true;
foreach (var p in AutoTestPatterns)
{
if (lower.StartsWith(p)) return true;
}
foreach (var d in AutoTestDomains)
{
if (lower.EndsWith(d)) return true;
}
return false;
}
private static bool IsDemoAccount(string email) =>
email.StartsWith("demo-", StringComparison.OrdinalIgnoreCase)
&& email.Contains("@food-market.local", StringComparison.OrdinalIgnoreCase);
}