From f4737b9797f69adf17e13a1eb85e6c2d71703b28 Mon Sep 17 00:00:00 2001 From: Rustam Khalimov Date: Fri, 11 Sep 2026 18:15:20 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B4=D0=B5=D1=82=D0=B5=D0=BA=D1=82=D0=BE=D1=80?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D0=BE=D0=B7=D1=80=D0=B8=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=D0=B9=20=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правило distinct_logins_per_ip (перебор логинов с одного IP) в SuspiciousActivityService; real-time учёт SuspiciousActivityReporter: метрика deal.security.suspicious{kind} и warn-лог на 429/блокировке входа. Закрывает BL-SUSPICIOUS. --- backlog.md | 2 +- .../Техническая-документация-Дейл.md | 5 +- src/core/Deal.Api/Endpoints/AuthEndpoints.cs | 5 +- .../Deal.Api/Middleware/RateLimitPolicies.cs | 10 +- src/core/Deal.Api/Program.cs | 1 + .../Services/SuspiciousActivityReporter.cs | 38 ++++++ .../Services/SuspiciousActivityService.cs | 53 +++++++++ .../Observability/DealMetrics.cs | 11 ++ .../Api/SuspiciousActivityReporterTests.cs | 111 ++++++++++++++++++ .../Tenants/SuspiciousActivityServiceTests.cs | 104 ++++++++++++++++ .../Support/LoginAttemptEndpointHttpTests.cs | 33 ++++++ .../Support/OperatorAuthHttpHost.cs | 1 + .../Support/SecurityEventCapture.cs | 78 ++++++++++++ 13 files changed, 447 insertions(+), 5 deletions(-) create mode 100644 src/core/Deal.Api/Services/SuspiciousActivityReporter.cs create mode 100644 src/core/tests/Deal.Tests.Unit/Api/SuspiciousActivityReporterTests.cs create mode 100644 src/core/tests/Deal.Tests.Unit/Support/SecurityEventCapture.cs diff --git a/backlog.md b/backlog.md index 0067932..016a19d 100644 --- a/backlog.md +++ b/backlog.md @@ -45,7 +45,7 @@ | BL-ALERT-BUDGET | **Сделано (2026-09-11):** метрика `deal.ai.budget.used.ratio{tenant}` (доля израсходованного ИИ-бюджета периода, 0..1) в `DealMetrics` + сбор в `RuntimeDepthsCollector`/`DealMetricsCollector`; на её основе оператор настраивает алерт в Prometheus/Grafana | этап 12, A | P2 | DONE | | BL-LOG-ACTOR | **Сделано (2026-09-11):** access-лог HTTP core (`HttpAccessLogMiddleware`) включает `actor` (login пользователя тенанта либо оператора) и `tenant` (id тенанта) — их берут из `HttpContext.Items` (Session/OperatorSession middleware) | этап 12, T6 | P3 | DONE | | BL-GRACEFUL | Дополнительные проверки устойчивости/ретраев (по результатам нагрузочного прогона) | этап 12, C | P2 | BACKLOG | -| BL-SUSPICIOUS | Расширение детектора подозрительной активности (правила/пороги по логам безопасности) | ТЗ §10.5, этап 12 | P3 | BACKLOG | +| BL-SUSPICIOUS | **Сделано (2026-09-11):** детектор `SuspiciousActivityService` расширен правилом `distinct_logins_per_ip` (перебор разных логинов с одного IP, порог `DistinctLoginsPerIpThreshold`); плюс real-time `SuspiciousActivityReporter` — метрика `deal.security.suspicious{kind}` + warn-лог на 429 rate limiter (`rate_limit`) и блокировке входа (`login_blocked`) | ТЗ §10.5, этап 12 | P3 | DONE | ## 5. Технический долг (качество/архитектура) diff --git a/docs/technical/Техническая-документация-Дейл.md b/docs/technical/Техническая-документация-Дейл.md index ece5fab..af315d0 100644 --- a/docs/technical/Техническая-документация-Дейл.md +++ b/docs/technical/Техническая-документация-Дейл.md @@ -1387,7 +1387,10 @@ docker compose -f deploy/compose.dev.yml start core telegram-service ml-service - **`/api/operator/health` (§10.2).** Добавлены `queues:{pipeline,mlOutbox}` и `sessions:{active}` (общий `RuntimeDepthsCollector`, без дублей SQL). - **Подозрительная активность (§10.5).** `SuspiciousActivityService` + `GET /api/operator/analytics/suspicious` - (всплеск неудачных входов по IP/логину, входы актора с множества IP, серии по тенанту; пороги — константы). + (всплеск неудачных входов по IP/логину, входы актора с множества IP, серии по тенанту, перебор разных + логинов с одного IP `distinct_logins_per_ip`; пороги — константы). Плюс real-time учёт + `SuspiciousActivityReporter`: метрика `deal.security.suspicious{kind}` и предупреждающий лог на 429 + rate limiter (`rate_limit`) и блокировке входа (`login_blocked`). - **«Открыть исходник» на карточке (§6.6)** и **темы оформления (§8.12, §15)** — во фронтенде. Итог: core-тесты **1275/1275**; фронт `npm run build` + `lint:i18n` зелёные. Контракт API — diff --git a/src/core/Deal.Api/Endpoints/AuthEndpoints.cs b/src/core/Deal.Api/Endpoints/AuthEndpoints.cs index 26e47b3..ce9f6df 100644 --- a/src/core/Deal.Api/Endpoints/AuthEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/AuthEndpoints.cs @@ -46,12 +46,15 @@ public static class AuthEndpoints IOptions cookieOptions, HttpContext context, CancellationToken ct, - LoginAttemptGuard loginAttemptGuard) + LoginAttemptGuard loginAttemptGuard, + SuspiciousActivityReporter suspicious) { string? attemptedLogin = NormalizeLogin(body.Login); if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct)) { + // Событие подозрительной активности: серия неудачных попыток входа → блокировка ключа. + suspicious.Report(SuspiciousActivityReporter.LoginBlockedKind, ClientIp(context)); return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail); } diff --git a/src/core/Deal.Api/Middleware/RateLimitPolicies.cs b/src/core/Deal.Api/Middleware/RateLimitPolicies.cs index 6c9a434..b984df1 100644 --- a/src/core/Deal.Api/Middleware/RateLimitPolicies.cs +++ b/src/core/Deal.Api/Middleware/RateLimitPolicies.cs @@ -126,7 +126,13 @@ public static class RateLimitPolicies private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken) { - context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; - await context.HttpContext.Response.WriteAsJsonAsync(new { detail = RejectedDetail }, cancellationToken); + HttpContext http = context.HttpContext; + // Событие подозрительной активности: сработал rate limiter (актор — тенант либо IP анонима). + SuspiciousActivityReporter? reporter = http.RequestServices.GetService(); + string actor = http.GetCurrentUser() is { } user ? user.TenantId.ToString("N") : ClientKey(http); + reporter?.Report(SuspiciousActivityReporter.RateLimitKind, actor); + + http.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await http.Response.WriteAsJsonAsync(new { detail = RejectedDetail }, cancellationToken); } } diff --git a/src/core/Deal.Api/Program.cs b/src/core/Deal.Api/Program.cs index 9a2b0c3..9cd9cc5 100644 --- a/src/core/Deal.Api/Program.cs +++ b/src/core/Deal.Api/Program.cs @@ -177,6 +177,7 @@ builder.Services.AddSingleton(rateLimitOptions); // Гвард попыток входа — scoped: его хранилище счётчиков (IRateLimitCounterStore) — scoped EF-адаптер // (public.rate_limit_counters). Активен только при Enabled (no-op иначе). builder.Services.AddScoped(); +builder.Services.AddScoped(); if (rateLimitOptions.Enabled) { builder.Services.AddDealRateLimiter(rateLimitOptions); diff --git a/src/core/Deal.Api/Services/SuspiciousActivityReporter.cs b/src/core/Deal.Api/Services/SuspiciousActivityReporter.cs new file mode 100644 index 0000000..bc6b7bb --- /dev/null +++ b/src/core/Deal.Api/Services/SuspiciousActivityReporter.cs @@ -0,0 +1,38 @@ +using System.Diagnostics; +using Deal.SharedKernel.Observability; +using Microsoft.Extensions.Logging; + +namespace Deal.Api.Services; + +/// +/// Признаки подозрительной активности и их учёт (метрика + предупреждающий лог). +/// +/// Логгер событий. +public sealed class SuspiciousActivityReporter(ILogger logger) +{ + /// + /// Вид: сработал rate limiter (слишком много запросов). + /// + public const string RateLimitKind = "rate_limit"; + + /// + /// Вид: вход заблокирован после серии неудачных попыток. + /// + public const string LoginBlockedKind = "login_blocked"; + + // Актор неизвестен (нет ни тенанта, ни IP). + private const string UnknownActor = "-"; + + /// + /// Учитывает событие подозрительной активности. + /// + /// Вид события (константы класса). + /// Актор: id тенанта либо IP; null/пусто — «-». + public void Report(string kind, string? actor) + { + ArgumentException.ThrowIfNullOrWhiteSpace(kind); + string resolvedActor = string.IsNullOrWhiteSpace(actor) ? UnknownActor : actor; + DealMetrics.SecurityEvents.Add(1, new TagList { { DealMetrics.SecurityKindTagName, kind } }); + logger.LogWarning("Подозрительная активность: {Kind}, actor={Actor}", kind, resolvedActor); + } +} diff --git a/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs b/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs index c836159..8e96083 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs @@ -39,6 +39,11 @@ public sealed class SuspiciousActivityService /// public const int AuthFailuresPerTenantThreshold = 20; + /// + /// Порог числа разных логинов в неудачных входах с одного IP за окно. + /// + public const int DistinctLoginsPerIpThreshold = 5; + // Кратность порога, с которой уровень поднимается до high (2× порог). private const int HighSeverityMultiplier = 2; @@ -62,6 +67,11 @@ public sealed class SuspiciousActivityService /// public const string KindAuthFailuresPerTenant = "auth_failures_per_tenant"; + /// + /// Правило: перебор разных логинов с одного IP. + /// + public const string KindDistinctLoginsPerIp = "distinct_logins_per_ip"; + /// /// Уровень находки /// @@ -126,6 +136,7 @@ public sealed class SuspiciousActivityService AddFailedLoginsPerLogin(records, findings); AddManyIpsPerActor(records, findings); AddAuthFailuresPerTenant(records, findings); + AddDistinctLoginsPerIp(records, findings); findings.Sort(static (left, right) => { @@ -255,6 +266,48 @@ public sealed class SuspiciousActivityService } } + // Правило «перебор разных логинов с одного IP» (credential stuffing). + // records: Записи окна. + // findings: Накопитель находок. + private static void AddDistinctLoginsPerIp(IReadOnlyList records, List findings) + { + var loginsByIp = new Dictionary>(StringComparer.Ordinal); + foreach (AuditRecordDto record in records) + { + if (!record.IsFailedLogin() || string.IsNullOrWhiteSpace(record.Ip)) + { + continue; + } + + string? login = ExtractLogin(record); + if (string.IsNullOrWhiteSpace(login)) + { + continue; + } + + if (!loginsByIp.TryGetValue(record.Ip, out HashSet? logins)) + { + logins = new HashSet(StringComparer.Ordinal); + loginsByIp[record.Ip] = logins; + } + + logins.Add(login); + } + + foreach ((string ip, HashSet logins) in loginsByIp) + { + if (logins.Count >= DistinctLoginsPerIpThreshold) + { + findings.Add(new SuspiciousFindingDto( + KindDistinctLoginsPerIp, + SeverityFor(logins.Count, DistinctLoginsPerIpThreshold), + ip, + logins.Count, + $"Разных логинов с IP {ip}: {logins.Count} за окно (перебор)")); + } + } + } + // Считает записи по ключу-селектору (пустые/неразобранные ключи пропускаются). // records: Записи окна. // predicate: Отбор записей правила. diff --git a/src/core/Deal.SharedKernel/Observability/DealMetrics.cs b/src/core/Deal.SharedKernel/Observability/DealMetrics.cs index ef221ee..3930b22 100644 --- a/src/core/Deal.SharedKernel/Observability/DealMetrics.cs +++ b/src/core/Deal.SharedKernel/Observability/DealMetrics.cs @@ -44,6 +44,11 @@ public static class DealMetrics /// public const string TenantTagName = "tenant"; + /// + /// Имя метки вида события безопасности + /// + public const string SecurityKindTagName = "kind"; + /// /// Имя метрики доли израсходованного ИИ-бюджета /// @@ -82,6 +87,12 @@ public static class DealMetrics public static readonly Counter AuditEvents = Meter.CreateCounter("deal.audit.events", description: "Записи аудита по типам и акторам."); + /// + /// События подозрительной активности по видам + /// + public static readonly Counter SecurityEvents = + Meter.CreateCounter("deal.security.suspicious", description: "События подозрительной активности по видам (rate_limit, login_blocked)."); + /// /// Суммарная глубина очереди пайплайна /// diff --git a/src/core/tests/Deal.Tests.Unit/Api/SuspiciousActivityReporterTests.cs b/src/core/tests/Deal.Tests.Unit/Api/SuspiciousActivityReporterTests.cs new file mode 100644 index 0000000..71a7bc9 --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Api/SuspiciousActivityReporterTests.cs @@ -0,0 +1,111 @@ +using Deal.Api.Services; +using Deal.Tests.Unit.Support; +using Microsoft.Extensions.Logging; + +namespace Deal.Tests.Unit.Api; + +/// +/// Unit-тесты учёта подозрительной активности +/// +public sealed class SuspiciousActivityReporterTests +{ + // Актор сценариев. + private const string Actor = "tenant-1"; + + /// + /// Report инкрементит deal.security.suspicious с меткой kind + /// + [Fact] + public void Report_IncrementsSecurityCounter_WithKindTag() + { + using var capture = new SecurityEventCapture(); + var reporter = new SuspiciousActivityReporter(new RecordingLogger()); + + reporter.Report(SuspiciousActivityReporter.RateLimitKind, Actor); + + Assert.Equal(1, capture.Sum(SuspiciousActivityReporter.RateLimitKind)); + (string? Kind, long Value) measurement = Assert.Single( + capture.Snapshot(), + item => item.Kind == SuspiciousActivityReporter.RateLimitKind); + Assert.Equal(1, measurement.Value); + } + + /// + /// Report пишет предупреждение с видом и актором + /// + [Fact] + public void Report_WritesWarningWithKindAndActor() + { + var logger = new RecordingLogger(); + var reporter = new SuspiciousActivityReporter(logger); + + reporter.Report(SuspiciousActivityReporter.LoginBlockedKind, Actor); + + LogEntry entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.Contains(SuspiciousActivityReporter.LoginBlockedKind, entry.Message); + Assert.Contains($"actor={Actor}", entry.Message); + } + + /// + /// null/пусто/пробелы actor → в логе «-» + /// + /// Пустой актор сценария. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Report_UnknownActor_WritesDash(string? actor) + { + var logger = new RecordingLogger(); + var reporter = new SuspiciousActivityReporter(logger); + + reporter.Report(SuspiciousActivityReporter.RateLimitKind, actor); + + LogEntry entry = Assert.Single(logger.Entries); + Assert.Contains("actor=-", entry.Message); + } + + /// + /// Пустой/пробельный kind → ArgumentException + /// + /// Пустой вид события сценария. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Report_EmptyOrWhitespaceKind_Throws(string? kind) + { + var reporter = new SuspiciousActivityReporter(new RecordingLogger()); + + Assert.ThrowsAny(() => reporter.Report(kind!, Actor)); + } + + // In-memory логгер: копит записи с уровнем, сообщением и исключением. + private sealed class RecordingLogger : ILogger + { + /// + /// Записи лога в порядке поступления. + /// + public List Entries { get; } = []; + + /// + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + /// + public bool IsEnabled(LogLevel logLevel) => true; + + /// + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + => Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception)); + } + + // Запись лога, снятая in-memory логгером. + private sealed record LogEntry(LogLevel Level, string Message, Exception? Exception); +} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SuspiciousActivityServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SuspiciousActivityServiceTests.cs index e56de32..1e2498b 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SuspiciousActivityServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SuspiciousActivityServiceTests.cs @@ -137,6 +137,91 @@ public sealed class SuspiciousActivityServiceTests Assert.Equal(Tenant.ToString("D"), finding.Subject); } + [Fact] + public async Task AnalyzeAsync_DistinctLoginsPerIp_TriggersAtThreshold() + { + var store = new FakeAuditLogStore(); + for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++) + { + SeedFailed(store, ip: "10.3.0.1", login: $"user{i}", minutesAgo: i); + } + + SuspiciousActivityService service = Create(store); + SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None); + + SuspiciousFindingDto finding = Assert.Single( + report.Items, + item => item.Kind == SuspiciousActivityService.KindDistinctLoginsPerIp); + Assert.Equal("10.3.0.1", finding.Subject); + Assert.Equal(SuspiciousActivityService.DistinctLoginsPerIpThreshold, finding.Count); + Assert.Equal(SuspiciousActivityService.SeverityMedium, finding.Severity); + } + + [Fact] + public async Task AnalyzeAsync_DistinctLoginsPerIp_DoubleThreshold_IsHigh() + { + var store = new FakeAuditLogStore(); + int count = SuspiciousActivityService.DistinctLoginsPerIpThreshold * 2; + for (int i = 0; i < count; i++) + { + SeedFailed(store, ip: "10.3.0.2", login: $"user{i}", minutesAgo: i); + } + + SuspiciousActivityService service = Create(store); + SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None); + + SuspiciousFindingDto finding = Assert.Single( + report.Items, + item => item.Kind == SuspiciousActivityService.KindDistinctLoginsPerIp); + Assert.Equal(count, finding.Count); + Assert.Equal(SuspiciousActivityService.SeverityHigh, finding.Severity); + } + + [Fact] + public async Task AnalyzeAsync_DistinctLoginsPerIp_BelowThreshold_NoFinding() + { + var store = new FakeAuditLogStore(); + for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold - 1; i++) + { + SeedFailed(store, ip: "10.3.0.3", login: $"user{i}", minutesAgo: i); + } + + SuspiciousActivityService service = Create(store); + SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None); + + Assert.DoesNotContain(report.Items, item => item.Kind == SuspiciousActivityService.KindDistinctLoginsPerIp); + } + + [Fact] + public async Task AnalyzeAsync_DistinctLoginsPerIp_SameLoginRepeated_NoFinding() + { + var store = new FakeAuditLogStore(); + for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold + 1; i++) + { + SeedFailed(store, ip: "10.3.0.4", login: "repeated", minutesAgo: i); + } + + SuspiciousActivityService service = Create(store); + SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None); + + Assert.DoesNotContain(report.Items, item => item.Kind == SuspiciousActivityService.KindDistinctLoginsPerIp); + } + + [Fact] + public async Task AnalyzeAsync_DistinctLoginsPerIp_SuccessfulLoginsIgnored() + { + var store = new FakeAuditLogStore(); + for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++) + { + SeedSuccess(store, ip: "10.3.0.5", login: $"user{i}", minutesAgo: i); + } + + SuspiciousActivityService service = Create(store); + SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None); + + Assert.DoesNotContain(report.Items, item => item.Kind == SuspiciousActivityService.KindDistinctLoginsPerIp); + } + [Fact] public async Task AnalyzeAsync_RecordsOutsideWindow_AreIgnored() { @@ -171,5 +256,24 @@ public sealed class SuspiciousActivityServiceTests CancellationToken.None); } + // Пишет запись «успешный вход» с заданным временем. + private static void SeedSuccess( + FakeAuditLogStore store, + string ip, + string login, + int minutesAgo) + { + store.AppendAsync( + new AuditRecordDto( + AuditEvents.TenantLoginOk, + AuditActorTypes.Tenant, + ActorId: null, + TenantId: null, + Ip: ip, + DetailJson: AuditService.ToDetailJson(new { login }), + At: Now.AddMinutes(-minutesAgo)), + CancellationToken.None); + } + private static SuspiciousActivityService Create(FakeAuditLogStore store) => new(store, () => Now); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/LoginAttemptEndpointHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/LoginAttemptEndpointHttpTests.cs index 1bb8200..83b75cd 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/LoginAttemptEndpointHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/LoginAttemptEndpointHttpTests.cs @@ -96,6 +96,39 @@ public sealed class LoginAttemptEndpointHttpTests rateLimitOptions: EnabledRateLimitOptions()); } + // ─── Учёт подозрительной активности ────────────────────────────── + + /// + /// Блокировка входа после серии неудач → событие login_blocked в метрике + /// + [Fact] + public async Task Login_WhenBlocked_ReportsLoginBlockedSecurityEvent() + { + using var capture = new SecurityEventCapture(); + + await OperatorAuthHttpHost.RunAsync( + new FakeOperatorAuthStore(), + NewUserStore(), + async (baseAddress, _, _, _, _, _, _) => + { + HttpClient client = CreateClient(baseAddress); + + for (int attempt = 0; attempt < MaxAttempts; attempt++) + { + using HttpResponseMessage failed = await PostJsonAsync( + client, $"{baseAddress}/api/auth/login", new { login = Login, password = WrongPassword }); + Assert.Equal(HttpStatusCode.Unauthorized, failed.StatusCode); + } + + using HttpResponseMessage blocked = await PostJsonAsync( + client, $"{baseAddress}/api/auth/login", new { login = Login, password = Password }); + Assert.Equal(HttpStatusCode.TooManyRequests, blocked.StatusCode); + }, + rateLimitOptions: EnabledRateLimitOptions()); + + Assert.True(capture.Sum(SuspiciousActivityReporter.LoginBlockedKind) >= 1); + } + // ─── Хелперы ───────────────────────────────────────────────────────── private static RateLimitOptions EnabledRateLimitOptions() => diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs index 415f3da..252b9cb 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs @@ -210,6 +210,7 @@ internal static class OperatorAuthHttpHost builder.Services.AddSingleton(effectiveRateLimitOptions); builder.Services.AddSingleton(new FakeRateLimitCounterStore()); builder.Services.AddScoped(); + builder.Services.AddScoped(); WebApplication app = builder.Build(); app.UseMiddleware(); diff --git a/src/core/tests/Deal.Tests.Unit/Support/SecurityEventCapture.cs b/src/core/tests/Deal.Tests.Unit/Support/SecurityEventCapture.cs new file mode 100644 index 0000000..7a31785 --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/SecurityEventCapture.cs @@ -0,0 +1,78 @@ +using System.Diagnostics.Metrics; +using Deal.SharedKernel.Observability; + +namespace Deal.Tests.Unit.Support; + +/// +/// Слушатель счётчика подозрительной активности deal.security.suspicious. +/// +public sealed class SecurityEventCapture : IDisposable +{ + // Защита накопленных измерений (callback может прийти из потока запроса). + private readonly object _gate = new(); + + private readonly List<(string? Kind, long Value)> _measurements = []; + + private readonly MeterListener _listener; + + /// + /// Начинает слушать счётчик подозрительной активности. + /// + public SecurityEventCapture() + { + _listener = new MeterListener(); + _listener.InstrumentPublished = (instrument, current) => + { + if (instrument.Meter.Name == DealMetrics.MeterName + && instrument.Name == DealMetrics.SecurityEvents.Name) + { + current.EnableMeasurementEvents(instrument); + } + }; + _listener.SetMeasurementEventCallback((_, value, tags, _) => + { + string? kind = null; + foreach (KeyValuePair tag in tags) + { + if (tag.Key == DealMetrics.SecurityKindTagName) + { + kind = tag.Value?.ToString(); + } + } + + lock (_gate) + { + _measurements.Add((kind, value)); + } + }); + _listener.Start(); + } + + /// + /// Снимок измерений с момента создания слушателя. + /// + /// Пары (вид события, значение инкремента). + public IReadOnlyList<(string? Kind, long Value)> Snapshot() + { + lock (_gate) + { + return [.. _measurements]; + } + } + + /// + /// Сумма инкрементов по виду события. + /// + /// Вид события (метка kind). + /// Суммарное значение измерений с указанным видом. + public long Sum(string kind) + { + lock (_gate) + { + return _measurements.Where(measurement => measurement.Kind == kind).Sum(measurement => measurement.Value); + } + } + + /// + public void Dispose() => _listener.Dispose(); +}