Перевести FakeAuditLogStore на NSubstitute

Хелпер Support/TestAuditLogStore: журнал + фильтры Query/Count, PurgeOlderThan,
Id присваивается при добавлении. Потребители (13 файлов) перетипизированы на
.Store, фейк удалён, тесты 1340 зелёные.
This commit is contained in:
2026-09-13 00:32:51 +03:00
parent be8e8654df
commit 79b1893b28
15 changed files with 153 additions and 148 deletions
@@ -24,9 +24,9 @@ public sealed class DataRetentionSchedulerTests
public async Task RunCycle_PurgesAgedAuditResetsExpiredLimitsAndDeletesExpiredCounters() public async Task RunCycle_PurgesAgedAuditResetsExpiredLimitsAndDeletesExpiredCounters()
{ {
DateTimeOffset now = DateTimeOffset.UtcNow; DateTimeOffset now = DateTimeOffset.UtcNow;
var audit = new FakeAuditLogStore(); var audit = new TestAuditLogStore();
await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None); await audit.Store.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None);
await audit.AppendAsync(AuditRecord(AuditEvents.TenantLoginOk, now.AddDays(-1)), CancellationToken.None); await audit.Store.AppendAsync(AuditRecord(AuditEvents.TenantLoginOk, now.AddDays(-1)), CancellationToken.None);
var limits = new FakeTenantLimitStore(); var limits = new FakeTenantLimitStore();
// Период месяца начат два месяца назад → истёк; строка с накоплениями должна обнулиться. // Период месяца начат два месяца назад → истёк; строка с накоплениями должна обнулиться.
@@ -63,8 +63,8 @@ public sealed class DataRetentionSchedulerTests
public async Task RunCycle_IsIdempotent() public async Task RunCycle_IsIdempotent()
{ {
DateTimeOffset now = DateTimeOffset.UtcNow; DateTimeOffset now = DateTimeOffset.UtcNow;
var audit = new FakeAuditLogStore(); var audit = new TestAuditLogStore();
await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None); await audit.Store.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None);
var limits = new FakeTenantLimitStore(); var limits = new FakeTenantLimitStore();
limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500); limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500);
await using ServiceProvider provider = BuildProvider(audit, limits, new TestRateLimitCounterStore()); await using ServiceProvider provider = BuildProvider(audit, limits, new TestRateLimitCounterStore());
@@ -86,12 +86,12 @@ public sealed class DataRetentionSchedulerTests
// counters: Хелпер счётчиков (окна посеяны сценарием). // counters: Хелпер счётчиков (окна посеяны сценарием).
// Возвращает: Провайдер с сервисами цикла. // Возвращает: Провайдер с сервисами цикла.
private static ServiceProvider BuildProvider( private static ServiceProvider BuildProvider(
FakeAuditLogStore audit, TestAuditLogStore audit,
FakeTenantLimitStore limits, FakeTenantLimitStore limits,
TestRateLimitCounterStore counters) TestRateLimitCounterStore counters)
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddScoped<IAuditLogStore>(_ => audit); services.AddScoped<IAuditLogStore>(_ => audit.Store);
services.AddScoped<ITenantLimitStore>(_ => limits); services.AddScoped<ITenantLimitStore>(_ => limits);
services.AddScoped<IRateLimitCounterStore>(_ => counters.Store); services.AddScoped<IRateLimitCounterStore>(_ => counters.Store);
return services.BuildServiceProvider(); return services.BuildServiceProvider();
@@ -1,6 +1,7 @@
using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Modules.Tenants;
@@ -14,8 +15,8 @@ public sealed class AuditServiceTests
[Fact] [Fact]
public async Task AppendAsync_SetsAtToUtcNow_AndSavesAllFields() public async Task AppendAsync_SetsAtToUtcNow_AndSavesAllFields()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
var service = new AuditService(store); var service = new AuditService(store.Store);
var actorId = Guid.NewGuid(); var actorId = Guid.NewGuid();
var tenantId = Guid.NewGuid(); var tenantId = Guid.NewGuid();
var before = DateTimeOffset.UtcNow; var before = DateTimeOffset.UtcNow;
@@ -43,11 +44,11 @@ public sealed class AuditServiceTests
[Fact] [Fact]
public async Task QueryAsync_ReturnsFilteredRecordsNewestFirst() public async Task QueryAsync_ReturnsFilteredRecordsNewestFirst()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
await AppendWithAtAsync(store, AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, DateTimeOffset.UtcNow.AddMinutes(-2)); await AppendWithAtAsync(store, AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, DateTimeOffset.UtcNow.AddMinutes(-2));
await AppendWithAtAsync(store, AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, tenantId: null, DateTimeOffset.UtcNow.AddMinutes(-1)); await AppendWithAtAsync(store, AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, tenantId: null, DateTimeOffset.UtcNow.AddMinutes(-1));
await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId: null, DateTimeOffset.UtcNow); await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId: null, DateTimeOffset.UtcNow);
var service = new AuditService(store); var service = new AuditService(store.Store);
var all = await service.QueryAsync(EmptyFilter(limit: 100), CancellationToken.None); var all = await service.QueryAsync(EmptyFilter(limit: 100), CancellationToken.None);
Assert.Equal(3, all.Count); Assert.Equal(3, all.Count);
@@ -71,7 +72,7 @@ public sealed class AuditServiceTests
[Fact] [Fact]
public async Task QueryAsync_AppliesAtRangeAndTenantFilters() public async Task QueryAsync_AppliesAtRangeAndTenantFilters()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
var tenantId = Guid.NewGuid(); var tenantId = Guid.NewGuid();
var outsideTenantId = Guid.NewGuid(); var outsideTenantId = Guid.NewGuid();
var from = DateTimeOffset.UtcNow.AddMinutes(-10); var from = DateTimeOffset.UtcNow.AddMinutes(-10);
@@ -79,7 +80,7 @@ public sealed class AuditServiceTests
await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId, from.AddMinutes(-1)); // вне окна await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId, from.AddMinutes(-1)); // вне окна
await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId, from); await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId, from);
await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, outsideTenantId, to); await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, outsideTenantId, to);
var service = new AuditService(store); var service = new AuditService(store.Store);
var byRange = await service.QueryAsync( var byRange = await service.QueryAsync(
new AuditQueryDto(AuditEvents.TenantLoginOk, null, null, from, to, 100), CancellationToken.None); new AuditQueryDto(AuditEvents.TenantLoginOk, null, null, from, to, 100), CancellationToken.None);
@@ -94,13 +95,13 @@ public sealed class AuditServiceTests
[Fact] [Fact]
public async Task CountAsync_CountsAllRowsMatchingFilterRegardlessOfLimit() public async Task CountAsync_CountsAllRowsMatchingFilterRegardlessOfLimit()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
for (int i = 0; i < 7; i++) for (int i = 0; i < 7; i++)
{ {
await AppendWithAtAsync(store, AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, DateTimeOffset.UtcNow); await AppendWithAtAsync(store, AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, DateTimeOffset.UtcNow);
} }
var service = new AuditService(store); var service = new AuditService(store.Store);
var filter = new AuditQueryDto(AuditEvents.OperatorLoginOk, null, null, null, null, Limit: 2); var filter = new AuditQueryDto(AuditEvents.OperatorLoginOk, null, null, null, null, Limit: 2);
Assert.Equal(7, await service.CountAsync(filter, CancellationToken.None)); Assert.Equal(7, await service.CountAsync(filter, CancellationToken.None));
@@ -163,13 +164,13 @@ public sealed class AuditServiceTests
// Добавляет запись в фейк с явным At/актором (минуя сервис — для проверок сортировки/фильтров). // Добавляет запись в фейк с явным At/актором (минуя сервис — для проверок сортировки/фильтров).
private static async Task AppendWithAtAsync( private static async Task AppendWithAtAsync(
FakeAuditLogStore store, TestAuditLogStore store,
string eventType, string eventType,
string actorType, string actorType,
Guid? tenantId, Guid? tenantId,
DateTimeOffset at) DateTimeOffset at)
{ {
await store.AppendAsync(new AuditRecordDto( await store.Store.AppendAsync(new AuditRecordDto(
eventType, eventType,
actorType, actorType,
ActorId: null, ActorId: null,
@@ -1,56 +0,0 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// In-memory реализация <see cref="IAuditLogStore"/> для юнит/HTTP-тестов аудита.
/// </summary>
public sealed class FakeAuditLogStore : IAuditLogStore
{
private readonly List<AuditRecordDto> _records = [];
/// <summary>
/// Записи хранилища в порядке добавления
/// </summary>
public IReadOnlyList<AuditRecordDto> Records => _records;
/// <inheritdoc />
public Task AppendAsync(AuditRecordDto record, CancellationToken ct)
{
_records.Add(record with { Id = _records.Count + 1 });
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<AuditRecordDto>> QueryAsync(AuditQueryDto filter, CancellationToken ct)
{
IReadOnlyList<AuditRecordDto> result = ApplyFilters(filter)
.OrderByDescending(r => r.At)
.Skip(Math.Max(0, filter.Offset))
.Take(Math.Max(1, Math.Min(AuditService.MaxQueryLimit, filter.Limit)))
.ToList();
return Task.FromResult(result);
}
/// <inheritdoc />
public Task<int> CountAsync(AuditQueryDto filter, CancellationToken ct) =>
Task.FromResult(ApplyFilters(filter).Count());
/// <inheritdoc />
public Task<int> PurgeOlderThanAsync(DateTimeOffset cutoff, CancellationToken ct)
{
int removed = _records.RemoveAll(record => record.At < cutoff);
return Task.FromResult(removed);
}
private IEnumerable<AuditRecordDto> ApplyFilters(AuditQueryDto filter) =>
_records.Where(r =>
(filter.EventType is null || r.EventType == filter.EventType) &&
(filter.ActorType is null || r.ActorType == filter.ActorType) &&
(filter.TenantId is null || r.TenantId == filter.TenantId) &&
(filter.ActorId is null || r.ActorId == filter.ActorId) &&
(filter.From is null || r.At >= filter.From.Value) &&
(filter.To is null || r.At <= filter.To.Value));
}
@@ -1,5 +1,6 @@
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Modules.Tenants;
@@ -14,7 +15,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_NoRecords_ReturnsEmpty() public async Task AnalyzeAsync_NoRecords_ReturnsEmpty()
{ {
SuspiciousActivityService service = Create(new FakeAuditLogStore()); SuspiciousActivityService service = Create(new TestAuditLogStore());
SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None); SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None);
@@ -26,7 +27,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_FailedLoginsPerIp_TriggersAtThreshold() public async Task AnalyzeAsync_FailedLoginsPerIp_TriggersAtThreshold()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++) for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++)
{ {
SeedFailed(store, ip: "10.0.0.1", login: "user", minutesAgo: i); SeedFailed(store, ip: "10.0.0.1", login: "user", minutesAgo: i);
@@ -46,7 +47,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_FailedLoginsPerIp_DoubleThreshold_IsHigh() public async Task AnalyzeAsync_FailedLoginsPerIp_DoubleThreshold_IsHigh()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
int count = SuspiciousActivityService.FailedLoginsPerIpThreshold * 2; int count = SuspiciousActivityService.FailedLoginsPerIpThreshold * 2;
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
@@ -66,7 +67,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_FailedLoginsPerLogin_Triggers() public async Task AnalyzeAsync_FailedLoginsPerLogin_Triggers()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerLoginThreshold; i++) for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerLoginThreshold; i++)
{ {
SeedFailed(store, ip: $"10.0.0.{i + 1}", login: "target", minutesAgo: i); SeedFailed(store, ip: $"10.0.0.{i + 1}", login: "target", minutesAgo: i);
@@ -84,11 +85,11 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_ManyIpsPerActor_Triggers() public async Task AnalyzeAsync_ManyIpsPerActor_Triggers()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
Guid actor = Guid.NewGuid(); Guid actor = Guid.NewGuid();
for (int i = 0; i < SuspiciousActivityService.DistinctIpsPerActorThreshold; i++) for (int i = 0; i < SuspiciousActivityService.DistinctIpsPerActorThreshold; i++)
{ {
await store.AppendAsync( await store.Store.AppendAsync(
new AuditRecordDto( new AuditRecordDto(
AuditEvents.TenantLoginOk, AuditEvents.TenantLoginOk,
AuditActorTypes.Tenant, AuditActorTypes.Tenant,
@@ -113,10 +114,10 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_AuthFailuresPerTenant_Triggers() public async Task AnalyzeAsync_AuthFailuresPerTenant_Triggers()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.AuthFailuresPerTenantThreshold; i++) for (int i = 0; i < SuspiciousActivityService.AuthFailuresPerTenantThreshold; i++)
{ {
await store.AppendAsync( await store.Store.AppendAsync(
new AuditRecordDto( new AuditRecordDto(
AuditEvents.TenantLoginFailed, AuditEvents.TenantLoginFailed,
AuditActorTypes.Tenant, AuditActorTypes.Tenant,
@@ -140,7 +141,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_TriggersAtThreshold() public async Task AnalyzeAsync_DistinctLoginsPerIp_TriggersAtThreshold()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++) for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++)
{ {
SeedFailed(store, ip: "10.3.0.1", login: $"user{i}", minutesAgo: i); SeedFailed(store, ip: "10.3.0.1", login: $"user{i}", minutesAgo: i);
@@ -160,7 +161,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_DoubleThreshold_IsHigh() public async Task AnalyzeAsync_DistinctLoginsPerIp_DoubleThreshold_IsHigh()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
int count = SuspiciousActivityService.DistinctLoginsPerIpThreshold * 2; int count = SuspiciousActivityService.DistinctLoginsPerIpThreshold * 2;
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
@@ -180,7 +181,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_BelowThreshold_NoFinding() public async Task AnalyzeAsync_DistinctLoginsPerIp_BelowThreshold_NoFinding()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold - 1; i++) for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold - 1; i++)
{ {
SeedFailed(store, ip: "10.3.0.3", login: $"user{i}", minutesAgo: i); SeedFailed(store, ip: "10.3.0.3", login: $"user{i}", minutesAgo: i);
@@ -195,7 +196,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_SameLoginRepeated_NoFinding() public async Task AnalyzeAsync_DistinctLoginsPerIp_SameLoginRepeated_NoFinding()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold + 1; i++) for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold + 1; i++)
{ {
SeedFailed(store, ip: "10.3.0.4", login: "repeated", minutesAgo: i); SeedFailed(store, ip: "10.3.0.4", login: "repeated", minutesAgo: i);
@@ -210,7 +211,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_SuccessfulLoginsIgnored() public async Task AnalyzeAsync_DistinctLoginsPerIp_SuccessfulLoginsIgnored()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++) for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++)
{ {
SeedSuccess(store, ip: "10.3.0.5", login: $"user{i}", minutesAgo: i); SeedSuccess(store, ip: "10.3.0.5", login: $"user{i}", minutesAgo: i);
@@ -225,7 +226,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact] [Fact]
public async Task AnalyzeAsync_RecordsOutsideWindow_AreIgnored() public async Task AnalyzeAsync_RecordsOutsideWindow_AreIgnored()
{ {
var store = new FakeAuditLogStore(); var store = new TestAuditLogStore();
for (int i = 0; i < 50; i++) for (int i = 0; i < 50; i++)
{ {
SeedFailed(store, ip: "10.0.0.1", login: "user", minutesAgo: 60 * 30); // 30 часов назад — вне суток SeedFailed(store, ip: "10.0.0.1", login: "user", minutesAgo: 60 * 30); // 30 часов назад — вне суток
@@ -239,12 +240,12 @@ public sealed class SuspiciousActivityServiceTests
// Пишет запись «неудачный вход» с заданным временем. // Пишет запись «неудачный вход» с заданным временем.
private static void SeedFailed( private static void SeedFailed(
FakeAuditLogStore store, TestAuditLogStore store,
string ip, string ip,
string login, string login,
int minutesAgo) int minutesAgo)
{ {
store.AppendAsync( store.Store.AppendAsync(
new AuditRecordDto( new AuditRecordDto(
AuditEvents.TenantLoginFailed, AuditEvents.TenantLoginFailed,
AuditActorTypes.Tenant, AuditActorTypes.Tenant,
@@ -258,12 +259,12 @@ public sealed class SuspiciousActivityServiceTests
// Пишет запись «успешный вход» с заданным временем. // Пишет запись «успешный вход» с заданным временем.
private static void SeedSuccess( private static void SeedSuccess(
FakeAuditLogStore store, TestAuditLogStore store,
string ip, string ip,
string login, string login,
int minutesAgo) int minutesAgo)
{ {
store.AppendAsync( store.Store.AppendAsync(
new AuditRecordDto( new AuditRecordDto(
AuditEvents.TenantLoginOk, AuditEvents.TenantLoginOk,
AuditActorTypes.Tenant, AuditActorTypes.Tenant,
@@ -275,5 +276,5 @@ public sealed class SuspiciousActivityServiceTests
CancellationToken.None); CancellationToken.None);
} }
private static SuspiciousActivityService Create(FakeAuditLogStore store) => new(store, () => Now); private static SuspiciousActivityService Create(TestAuditLogStore store) => new(store.Store, () => Now);
} }
@@ -40,7 +40,7 @@ public sealed class JoinEndpointHttpTests
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var provisioner = new TestTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await RunAsync( await RunAsync(
inviteStore, tenantStore, provisioner.Provisioner, authStore, auditStore, inviteStore, tenantStore, provisioner.Provisioner, authStore, auditStore,
@@ -83,7 +83,7 @@ public sealed class JoinEndpointHttpTests
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
await RunAsync( await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(), inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using (HttpResponseMessage first = await PostJsonAsync( using (HttpResponseMessage first = await PostJsonAsync(
@@ -111,7 +111,7 @@ public sealed class JoinEndpointHttpTests
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
await RunAsync( await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(), inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -132,7 +132,7 @@ public sealed class JoinEndpointHttpTests
inviteStore.AddInvite(NewInvite(Email, status: InviteStatuses.Revoked)); inviteStore.AddInvite(NewInvite(Email, status: InviteStatuses.Revoked));
await RunAsync( await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(), inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new TestAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -150,7 +150,7 @@ public sealed class JoinEndpointHttpTests
inviteStore.AddInvite(NewInvite(Email, expiresAt: DateTimeOffset.UtcNow.AddHours(-1))); inviteStore.AddInvite(NewInvite(Email, expiresAt: DateTimeOffset.UtcNow.AddHours(-1)));
await RunAsync( await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(), inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new TestAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -165,7 +165,7 @@ public sealed class JoinEndpointHttpTests
public async Task Join_WithUnknownCode_Returns400NotFound() public async Task Join_WithUnknownCode_Returns400NotFound()
{ {
await RunAsync( await RunAsync(
new TestInviteStore(), new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(), new TestInviteStore(), new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new TestAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -183,7 +183,7 @@ public sealed class JoinEndpointHttpTests
inviteStore.AddInvite(NewInvite(Email)); inviteStore.AddInvite(NewInvite(Email));
await RunAsync( await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(), inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new TestAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -205,7 +205,7 @@ public sealed class JoinEndpointHttpTests
authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: "active", "hash")); authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: "active", "hash"));
await RunAsync( await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(), inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -231,7 +231,7 @@ public sealed class JoinEndpointHttpTests
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
await RunAsync( await RunAsync(
inviteStore, tenantStore, provisioner.Provisioner, authStore, new FakeAuditLogStore(), inviteStore, tenantStore, provisioner.Provisioner, authStore, new TestAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -253,7 +253,7 @@ public sealed class JoinEndpointHttpTests
TestTenantStore tenantStore, TestTenantStore tenantStore,
ITenantProvisioner provisioner, ITenantProvisioner provisioner,
FakeAuthStore authStore, FakeAuthStore authStore,
FakeAuditLogStore auditStore, TestAuditLogStore auditStore,
Func<string, HttpClient, Task> scenario) Func<string, HttpClient, Task> scenario)
{ {
int port = TestPort.Allocate(); int port = TestPort.Allocate();
@@ -265,7 +265,7 @@ public sealed class JoinEndpointHttpTests
// последняя регистрация (зеркало OperatorAuthHttpHost). // последняя регистрация (зеркало OperatorAuthHttpHost).
builder.Services.AddSingleton(TestHashers.New()); builder.Services.AddSingleton(TestHashers.New());
builder.Services.AddSingleton<IAuthStore>(authStore); builder.Services.AddSingleton<IAuthStore>(authStore);
builder.Services.AddSingleton<IAuditLogStore>(auditStore); builder.Services.AddSingleton<IAuditLogStore>(auditStore.Store);
builder.Services.AddSingleton<IInviteStore>(inviteStore.Store); builder.Services.AddSingleton<IInviteStore>(inviteStore.Store);
builder.Services.AddSingleton<ITenantRepository>(tenantStore.Repository); builder.Services.AddSingleton<ITenantRepository>(tenantStore.Repository);
builder.Services.AddSingleton<ITenantProvisioner>(provisioner); builder.Services.AddSingleton<ITenantProvisioner>(provisioner);
@@ -43,7 +43,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
[Fact] [Fact]
public async Task Overview_ReturnsTenantsTokensEventsAndLoginCounters() public async Task Overview_ReturnsTenantsTokensEventsAndLoginCounters()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await SeedAuditAsync(auditStore, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, ActiveTenant); await SeedAuditAsync(auditStore, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, ActiveTenant);
await SeedAuditAsync(auditStore, AuditEvents.TenantLogout, AuditActorTypes.Tenant, ActiveTenant); await SeedAuditAsync(auditStore, AuditEvents.TenantLogout, AuditActorTypes.Tenant, ActiveTenant);
await SeedAuditAsync(auditStore, AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, ActiveTenant); await SeedAuditAsync(auditStore, AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, ActiveTenant);
@@ -83,10 +83,10 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
[Fact] [Fact]
public async Task Suspicious_ReturnsFindingsForFailedLoginBurst() public async Task Suspicious_ReturnsFindingsForFailedLoginBurst()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++) for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++)
{ {
await auditStore.AppendAsync( await auditStore.Store.AppendAsync(
new AuditRecordDto( new AuditRecordDto(
AuditEvents.TenantLoginFailed, AuditEvents.TenantLoginFailed,
AuditActorTypes.Tenant, AuditActorTypes.Tenant,
@@ -179,7 +179,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
[Fact] [Fact]
public async Task Activity_FiltersByActorIdAndPaginates() public async Task Activity_FiltersByActorIdAndPaginates()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
Guid actor = Guid.NewGuid(); Guid actor = Guid.NewGuid();
await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30); await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30);
await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20); await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20);
@@ -222,7 +222,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
[Fact] [Fact]
public async Task Audit_FiltersByActorIdAndOffset() public async Task Audit_FiltersByActorIdAndOffset()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
Guid actor = Guid.NewGuid(); Guid actor = Guid.NewGuid();
await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30); await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30);
await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20); await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20);
@@ -275,13 +275,13 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
} }
private static Task SeedAuditAsync( private static Task SeedAuditAsync(
FakeAuditLogStore store, TestAuditLogStore store,
string eventType, string eventType,
string actorType, string actorType,
Guid? tenantId, Guid? tenantId,
Guid? actorId = null, Guid? actorId = null,
int minutesAgo = 1) => int minutesAgo = 1) =>
store.AppendAsync( store.Store.AppendAsync(
new AuditRecordDto( new AuditRecordDto(
eventType, eventType,
actorType, actorType,
@@ -39,7 +39,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact] [Fact]
public async Task OperatorLoginSuccess_WritesOperatorLoginOkAuditRecord() public async Task OperatorLoginSuccess_WritesOperatorLoginOkAuditRecord()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync( await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(), NewOperatorStore(),
@@ -65,7 +65,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact] [Fact]
public async Task OperatorLoginFailure_WritesOperatorLoginFailedAuditRecord() public async Task OperatorLoginFailure_WritesOperatorLoginFailedAuditRecord()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync( await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(), NewOperatorStore(),
@@ -91,7 +91,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact] [Fact]
public async Task TenantLoginSuccessAndFailure_WriteTenantAuditRecords() public async Task TenantLoginSuccessAndFailure_WriteTenantAuditRecords()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync( await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(), NewOperatorStore(),
@@ -137,9 +137,9 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact] [Fact]
public async Task Audit_ReturnsItemsNewestFirstWithTotal() public async Task Audit_ReturnsItemsNewestFirstWithTotal()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
// Историческая запись со старым At — гарантированно последняя в выборке (проверка At DESC). // Историческая запись со старым At — гарантированно последняя в выборке (проверка At DESC).
await auditStore.AppendAsync(new AuditRecordDto( await auditStore.Store.AppendAsync(new AuditRecordDto(
AuditEvents.TenantCreated, AuditEvents.TenantCreated,
AuditActorTypes.Operator, AuditActorTypes.Operator,
ActorId: Guid.NewGuid(), ActorId: Guid.NewGuid(),
@@ -177,7 +177,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact] [Fact]
public async Task Audit_FiltersByEventTypeAndActorType() public async Task Audit_FiltersByEventTypeAndActorType()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync( await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(), NewOperatorStore(),
@@ -218,7 +218,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact] [Fact]
public async Task TenantLogout_WritesTenantLogoutAuditRecord() public async Task TenantLogout_WritesTenantLogoutAuditRecord()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync( await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(), NewOperatorStore(),
@@ -251,7 +251,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact] [Fact]
public async Task OperatorLogout_WritesOperatorLogoutAuditRecord() public async Task OperatorLogout_WritesOperatorLogoutAuditRecord()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync( await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(), NewOperatorStore(),
@@ -43,7 +43,7 @@ internal static class OperatorAuthHttpHost
FakeOperatorAuthStore operatorStore, FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore, FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, Task> scenario, Func<string, FakeOperatorAuthStore, FakeAuthStore, Task> scenario,
FakeAuditLogStore? auditStore = null) => TestAuditLogStore? auditStore = null) =>
await RunCoreAsync( await RunCoreAsync(
operatorStore, operatorStore,
userStore, userStore,
@@ -64,8 +64,8 @@ internal static class OperatorAuthHttpHost
public static async Task RunAsync( public static async Task RunAsync(
FakeOperatorAuthStore operatorStore, FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore, FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestInviteStore, FakeAuditLogStore, Task> scenario, Func<string, FakeOperatorAuthStore, FakeAuthStore, TestInviteStore, TestAuditLogStore, Task> scenario,
FakeAuditLogStore? auditStore = null, TestAuditLogStore? auditStore = null,
TestInviteStore? inviteStore = null) => TestInviteStore? inviteStore = null) =>
await RunCoreAsync( await RunCoreAsync(
operatorStore, operatorStore,
@@ -88,8 +88,8 @@ internal static class OperatorAuthHttpHost
public static async Task RunAsync( public static async Task RunAsync(
FakeOperatorAuthStore operatorStore, FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore, FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, Task> scenario, Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, Task> scenario,
FakeAuditLogStore? auditStore = null, TestAuditLogStore? auditStore = null,
TestInviteStore? inviteStore = null, TestInviteStore? inviteStore = null,
TestTenantStore? tenantStore = null) => TestTenantStore? tenantStore = null) =>
await RunCoreAsync( await RunCoreAsync(
@@ -115,8 +115,8 @@ internal static class OperatorAuthHttpHost
public static async Task RunAsync( public static async Task RunAsync(
FakeOperatorAuthStore operatorStore, FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore, FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario, Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, FakeTenantLimitStore, Task> scenario,
FakeAuditLogStore? auditStore = null, TestAuditLogStore? auditStore = null,
TestInviteStore? inviteStore = null, TestInviteStore? inviteStore = null,
TestTenantStore? tenantStore = null, TestTenantStore? tenantStore = null,
FakeTenantLimitStore? limitStore = null, FakeTenantLimitStore? limitStore = null,
@@ -144,8 +144,8 @@ internal static class OperatorAuthHttpHost
public static async Task RunWithGlobalSettingsAsync( public static async Task RunWithGlobalSettingsAsync(
FakeOperatorAuthStore operatorStore, FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore, FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestGlobalSettingsStore, FakeAuditLogStore, Task> scenario, Func<string, FakeOperatorAuthStore, FakeAuthStore, TestGlobalSettingsStore, TestAuditLogStore, Task> scenario,
FakeAuditLogStore? auditStore = null, TestAuditLogStore? auditStore = null,
TestGlobalSettingsStore? globalSettingsStore = null) => TestGlobalSettingsStore? globalSettingsStore = null) =>
await RunCoreAsync( await RunCoreAsync(
operatorStore, operatorStore,
@@ -161,16 +161,16 @@ internal static class OperatorAuthHttpHost
private static async Task RunCoreAsync( private static async Task RunCoreAsync(
FakeOperatorAuthStore operatorStore, FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore, FakeAuthStore userStore,
FakeAuditLogStore? auditStore, TestAuditLogStore? auditStore,
TestInviteStore? inviteStore, TestInviteStore? inviteStore,
TestTenantStore? tenantStore, TestTenantStore? tenantStore,
FakeTenantLimitStore? limitStore, FakeTenantLimitStore? limitStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, TestGlobalSettingsStore, Task> scenario, Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, FakeTenantLimitStore, TestGlobalSettingsStore, Task> scenario,
RateLimitOptions? rateLimitOptions = null, RateLimitOptions? rateLimitOptions = null,
TestTokenUsageEventStore? tokenUsageStore = null, TestTokenUsageEventStore? tokenUsageStore = null,
TestGlobalSettingsStore? globalSettingsStore = null) TestGlobalSettingsStore? globalSettingsStore = null)
{ {
FakeAuditLogStore effectiveAuditStore = auditStore ?? new FakeAuditLogStore(); TestAuditLogStore effectiveAuditStore = auditStore ?? new TestAuditLogStore();
TestInviteStore effectiveInviteStore = inviteStore ?? new TestInviteStore(); TestInviteStore effectiveInviteStore = inviteStore ?? new TestInviteStore();
TestTenantStore effectiveTenantStore = tenantStore ?? new TestTenantStore(); TestTenantStore effectiveTenantStore = tenantStore ?? new TestTenantStore();
FakeTenantLimitStore effectiveLimitStore = limitStore ?? new FakeTenantLimitStore(); FakeTenantLimitStore effectiveLimitStore = limitStore ?? new FakeTenantLimitStore();
@@ -188,7 +188,7 @@ internal static class OperatorAuthHttpHost
builder.Services.AddSingleton<ITenantContext, TenantContext>(); builder.Services.AddSingleton<ITenantContext, TenantContext>();
builder.Services.AddSingleton<IAuthStore>(userStore); builder.Services.AddSingleton<IAuthStore>(userStore);
builder.Services.AddSingleton<IOperatorAuthStore>(operatorStore); builder.Services.AddSingleton<IOperatorAuthStore>(operatorStore);
builder.Services.AddSingleton<IAuditLogStore>(effectiveAuditStore); builder.Services.AddSingleton<IAuditLogStore>(effectiveAuditStore.Store);
builder.Services.AddSingleton<IInviteStore>(effectiveInviteStore.Store); builder.Services.AddSingleton<IInviteStore>(effectiveInviteStore.Store);
builder.Services.AddSingleton<ITenantRepository>(effectiveTenantStore.Repository); builder.Services.AddSingleton<ITenantRepository>(effectiveTenantStore.Repository);
builder.Services.AddSingleton<ITenantLimitStore>(effectiveLimitStore); builder.Services.AddSingleton<ITenantLimitStore>(effectiveLimitStore);
@@ -92,7 +92,7 @@ public sealed class OperatorHealthEndpointsHttpTests
} }
// Прогоняет сценарий на хосте с активным оператором operator/operator (реестр/лимиты пустые). // Прогоняет сценарий на хосте с активным оператором operator/operator (реестр/лимиты пустые).
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario) => private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, FakeTenantLimitStore, Task> scenario) =>
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario); OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario);
// Фейк-хранилище оператора с активным оператором operator/operator. // Фейк-хранилище оператора с активным оператором operator/operator.
@@ -70,7 +70,7 @@ public sealed class OperatorInvitesEndpointsHttpTests
[Fact] [Fact]
public async Task CreateThenListThenRevoke_FullOperatorFlow_WorksAndWritesAudit() public async Task CreateThenListThenRevoke_FullOperatorFlow_WorksAndWritesAudit()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync( await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(), NewOperatorStore(),
@@ -120,7 +120,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
[Fact] [Fact]
public async Task PatchLimit_ResetsFlags_UpdatesBudgetAndWritesAudit() public async Task PatchLimit_ResetsFlags_UpdatesBudgetAndWritesAudit()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
const long newBudget = 20_000_000; const long newBudget = 20_000_000;
await RunAsync( await RunAsync(
@@ -165,7 +165,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
[Fact] [Fact]
public async Task PatchLimit_OnlyPeriod_KeepsBudgetAndWritesAudit() public async Task PatchLimit_OnlyPeriod_KeepsBudgetAndWritesAudit()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await RunAsync( await RunAsync(
async (baseAddress, operatorStore, _, _, _, _, _) => async (baseAddress, operatorStore, _, _, _, _, _) =>
@@ -195,7 +195,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
[Fact] [Fact]
public async Task PatchLimit_WithSameValues_IsIdempotent_WithoutNewAudit() public async Task PatchLimit_WithSameValues_IsIdempotent_WithoutNewAudit()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
const long budget = 25_000_000; const long budget = 25_000_000;
await RunAsync( await RunAsync(
@@ -284,7 +284,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
$"{baseAddress}/api/operator/tenants/{tenantId}/limit"; $"{baseAddress}/api/operator/tenants/{tenantId}/limit";
// Прогоняет сценарий на хосте с двумя активными тенантами и предзаполненными строками лимитов. // Прогоняет сценарий на хосте с двумя активными тенантами и предзаполненными строками лимитов.
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario, FakeAuditLogStore? auditStore = null) private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, FakeTenantLimitStore, Task> scenario, TestAuditLogStore? auditStore = null)
{ {
// Первый тенант: расход 80% месячного бюджета (8 000 000 / 10 000 000) + флаг Warned80; // Первый тенант: расход 80% месячного бюджета (8 000 000 / 10 000 000) + флаг Warned80;
// второй: небольшой бюджет без расхода. Дата начала периода — текущая (reset не срабатывает). // второй: небольшой бюджет без расхода. Дата начала периода — текущая (reset не срабатывает).
@@ -72,7 +72,7 @@ public sealed class OperatorMaintenanceEndpointsHttpTests
} }
// Прогоняет сценарий на хосте с активным оператором operator/operator и заданным реестром тенантов. // Прогоняет сценарий на хосте с активным оператором operator/operator и заданным реестром тенантов.
private static Task RunAsync(TestTenantStore tenantStore, Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, Task> scenario) => private static Task RunAsync(TestTenantStore tenantStore, Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, Task> scenario) =>
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario, tenantStore: tenantStore); OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario, tenantStore: tenantStore);
// Фейк-хранилище оператора с активным оператором operator/operator. // Фейк-хранилище оператора с активным оператором operator/operator.
@@ -61,7 +61,7 @@ public sealed class OperatorSettingsEndpointsHttpTests
[Fact] [Fact]
public async Task PutTelegramKeys_SavesEncryptedKeysMasksResponseAndWritesAudit() public async Task PutTelegramKeys_SavesEncryptedKeysMasksResponseAndWritesAudit()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await RunAsync( await RunAsync(
async (baseAddress, operatorStore, _, globalSettings, audit) => async (baseAddress, operatorStore, _, globalSettings, audit) =>
@@ -257,7 +257,7 @@ public sealed class OperatorSettingsEndpointsHttpTests
$"{baseAddress}/api/operator/settings/telegram-keys"; $"{baseAddress}/api/operator/settings/telegram-keys";
// Прогоняет сценарий на хосте с фейком глобального хранилища и аудита. // Прогоняет сценарий на хосте с фейком глобального хранилища и аудита.
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, TestGlobalSettingsStore, FakeAuditLogStore, Task> scenario, FakeAuditLogStore? auditStore = null) => private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, TestGlobalSettingsStore, TestAuditLogStore, Task> scenario, TestAuditLogStore? auditStore = null) =>
OperatorAuthHttpHost.RunWithGlobalSettingsAsync( OperatorAuthHttpHost.RunWithGlobalSettingsAsync(
NewOperatorStore(), NewOperatorStore(),
new FakeAuthStore(), new FakeAuthStore(),
@@ -97,7 +97,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
[Fact] [Fact]
public async Task Create_ReturnsCreatedTenant_AndWritesTenantCreatedAudit() public async Task Create_ReturnsCreatedTenant_AndWritesTenantCreatedAudit()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await RunAsync( await RunAsync(
async (baseAddress, operatorStore, _, tenantStore, _) => async (baseAddress, operatorStore, _, tenantStore, _) =>
@@ -138,7 +138,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
[Fact] [Fact]
public async Task Create_WithOwnerEmail_ReturnsOneTimePasswordAndCreatesOwner() public async Task Create_WithOwnerEmail_ReturnsOneTimePasswordAndCreatesOwner()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
const string ownerEmail = "owner@created.com"; const string ownerEmail = "owner@created.com";
await RunAsync( await RunAsync(
@@ -237,7 +237,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
[Fact] [Fact]
public async Task Suspend_BlocksTenantLogin_ThenUnsuspend_RestoresLogin() public async Task Suspend_BlocksTenantLogin_ThenUnsuspend_RestoresLogin()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await RunAsync( await RunAsync(
async (baseAddress, operatorStore, userStore, tenantStore, _) => async (baseAddress, operatorStore, userStore, tenantStore, _) =>
@@ -300,7 +300,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
[Fact] [Fact]
public async Task Suspend_AlreadySuspendedTenant_IsOkWithoutDuplicateAudit() public async Task Suspend_AlreadySuspendedTenant_IsOkWithoutDuplicateAudit()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
var tenantStore = new TestTenantStore( var tenantStore = new TestTenantStore(
new TenantRecordDto(TenantId, TenantName, TenantStatuses.Suspended, DateTimeOffset.UtcNow)); new TenantRecordDto(TenantId, TenantName, TenantStatuses.Suspended, DateTimeOffset.UtcNow));
@@ -342,7 +342,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
[Fact] [Fact]
public async Task Impersonate_ReturnsSessionToken_WorksAsDealSession_AndLogoutWritesStoppedAudit() public async Task Impersonate_ReturnsSessionToken_WorksAsDealSession_AndLogoutWritesStoppedAudit()
{ {
var auditStore = new FakeAuditLogStore(); var auditStore = new TestAuditLogStore();
await RunAsync( await RunAsync(
async (baseAddress, operatorStore, userStore, _, _) => async (baseAddress, operatorStore, userStore, _, _) =>
@@ -486,8 +486,8 @@ public sealed class OperatorTenantsEndpointsHttpTests
// Прогоняет сценарий на хосте с одним активным тенантом и одним пользователем в нём. // Прогоняет сценарий на хосте с одним активным тенантом и одним пользователем в нём.
private static Task RunAsync( private static Task RunAsync(
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, FakeAuditLogStore, Task> scenario, Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestAuditLogStore, Task> scenario,
FakeAuditLogStore? auditStore = null, TestAuditLogStore? auditStore = null,
TestTenantStore? tenantStore = null) TestTenantStore? tenantStore = null)
{ {
// По умолчанию в реестре — активный целевой тенант (пользователь в нём — NewUserStore). // По умолчанию в реестре — активный целевой тенант (пользователь в нём — NewUserStore).
@@ -503,7 +503,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
// Проверяет запись tenant_status_changed с заданным статусом и актором-оператором. // Проверяет запись tenant_status_changed с заданным статусом и актором-оператором.
private static void AssertStatusChangedAudit( private static void AssertStatusChangedAudit(
FakeAuditLogStore auditStore, TestAuditLogStore auditStore,
FakeOperatorAuthStore operatorStore, FakeOperatorAuthStore operatorStore,
string status) string status)
{ {
@@ -0,0 +1,59 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="IAuditLogStore"/> на списках: сервисы получают NSubstitute-подставку
/// (<see cref="Store"/>), тесты проверяют записи через <see cref="Records"/>.
/// </summary>
public sealed class TestAuditLogStore
{
private readonly List<AuditRecordDto> _records = [];
/// <summary>
/// Подставка порта аудита (создаётся в конструкторе).
/// </summary>
public IAuditLogStore Store { get; }
/// <summary>
/// Записи хранилища в порядке добавления.
/// </summary>
public IReadOnlyList<AuditRecordDto> Records => _records;
/// <summary>
/// Создаёт подставку с пустым журналом.
/// </summary>
public TestAuditLogStore()
{
Store = Substitute.For<IAuditLogStore>();
Store.When(s => s.AppendAsync(Arg.Any<AuditRecordDto>(), Arg.Any<CancellationToken>()))
.Do(ci => _records.Add(ci.Arg<AuditRecordDto>() with { Id = _records.Count + 1 }));
Store.QueryAsync(Arg.Any<AuditQueryDto>(), Arg.Any<CancellationToken>())
.Returns(ci => Query(ci.Arg<AuditQueryDto>()));
Store.CountAsync(Arg.Any<AuditQueryDto>(), Arg.Any<CancellationToken>())
.Returns(ci => ApplyFilters(ci.Arg<AuditQueryDto>()).Count());
Store.PurgeOlderThanAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(ci => _records.RemoveAll(record => record.At < ci.ArgAt<DateTimeOffset>(0)));
}
private IReadOnlyList<AuditRecordDto> Query(AuditQueryDto filter)
{
return ApplyFilters(filter)
.OrderByDescending(r => r.At)
.Skip(Math.Max(0, filter.Offset))
.Take(Math.Max(1, Math.Min(AuditService.MaxQueryLimit, filter.Limit)))
.ToList();
}
private IEnumerable<AuditRecordDto> ApplyFilters(AuditQueryDto filter) =>
_records.Where(r =>
(filter.EventType is null || r.EventType == filter.EventType) &&
(filter.ActorType is null || r.ActorType == filter.ActorType) &&
(filter.TenantId is null || r.TenantId == filter.TenantId) &&
(filter.ActorId is null || r.ActorId == filter.ActorId) &&
(filter.From is null || r.At >= filter.From.Value) &&
(filter.To is null || r.At <= filter.To.Value));
}