Перевести 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()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
var audit = new FakeAuditLogStore();
await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None);
await audit.AppendAsync(AuditRecord(AuditEvents.TenantLoginOk, now.AddDays(-1)), CancellationToken.None);
var audit = new TestAuditLogStore();
await audit.Store.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None);
await audit.Store.AppendAsync(AuditRecord(AuditEvents.TenantLoginOk, now.AddDays(-1)), CancellationToken.None);
var limits = new FakeTenantLimitStore();
// Период месяца начат два месяца назад → истёк; строка с накоплениями должна обнулиться.
@@ -63,8 +63,8 @@ public sealed class DataRetentionSchedulerTests
public async Task RunCycle_IsIdempotent()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
var audit = new FakeAuditLogStore();
await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None);
var audit = new TestAuditLogStore();
await audit.Store.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None);
var limits = new FakeTenantLimitStore();
limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500);
await using ServiceProvider provider = BuildProvider(audit, limits, new TestRateLimitCounterStore());
@@ -86,12 +86,12 @@ public sealed class DataRetentionSchedulerTests
// counters: Хелпер счётчиков (окна посеяны сценарием).
// Возвращает: Провайдер с сервисами цикла.
private static ServiceProvider BuildProvider(
FakeAuditLogStore audit,
TestAuditLogStore audit,
FakeTenantLimitStore limits,
TestRateLimitCounterStore counters)
{
var services = new ServiceCollection();
services.AddScoped<IAuditLogStore>(_ => audit);
services.AddScoped<IAuditLogStore>(_ => audit.Store);
services.AddScoped<ITenantLimitStore>(_ => limits);
services.AddScoped<IRateLimitCounterStore>(_ => counters.Store);
return services.BuildServiceProvider();
@@ -1,6 +1,7 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Tenants;
@@ -14,8 +15,8 @@ public sealed class AuditServiceTests
[Fact]
public async Task AppendAsync_SetsAtToUtcNow_AndSavesAllFields()
{
var store = new FakeAuditLogStore();
var service = new AuditService(store);
var store = new TestAuditLogStore();
var service = new AuditService(store.Store);
var actorId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var before = DateTimeOffset.UtcNow;
@@ -43,11 +44,11 @@ public sealed class AuditServiceTests
[Fact]
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.TenantLoginFailed, AuditActorTypes.Tenant, tenantId: null, DateTimeOffset.UtcNow.AddMinutes(-1));
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);
Assert.Equal(3, all.Count);
@@ -71,7 +72,7 @@ public sealed class AuditServiceTests
[Fact]
public async Task QueryAsync_AppliesAtRangeAndTenantFilters()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
var tenantId = Guid.NewGuid();
var outsideTenantId = Guid.NewGuid();
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);
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(
new AuditQueryDto(AuditEvents.TenantLoginOk, null, null, from, to, 100), CancellationToken.None);
@@ -94,13 +95,13 @@ public sealed class AuditServiceTests
[Fact]
public async Task CountAsync_CountsAllRowsMatchingFilterRegardlessOfLimit()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
for (int i = 0; i < 7; i++)
{
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);
Assert.Equal(7, await service.CountAsync(filter, CancellationToken.None));
@@ -163,13 +164,13 @@ public sealed class AuditServiceTests
// Добавляет запись в фейк с явным At/актором (минуя сервис — для проверок сортировки/фильтров).
private static async Task AppendWithAtAsync(
FakeAuditLogStore store,
TestAuditLogStore store,
string eventType,
string actorType,
Guid? tenantId,
DateTimeOffset at)
{
await store.AppendAsync(new AuditRecordDto(
await store.Store.AppendAsync(new AuditRecordDto(
eventType,
actorType,
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.Services;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Tenants;
@@ -14,7 +15,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
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);
@@ -26,7 +27,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_FailedLoginsPerIp_TriggersAtThreshold()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++)
{
SeedFailed(store, ip: "10.0.0.1", login: "user", minutesAgo: i);
@@ -46,7 +47,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_FailedLoginsPerIp_DoubleThreshold_IsHigh()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
int count = SuspiciousActivityService.FailedLoginsPerIpThreshold * 2;
for (int i = 0; i < count; i++)
{
@@ -66,7 +67,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_FailedLoginsPerLogin_Triggers()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerLoginThreshold; i++)
{
SeedFailed(store, ip: $"10.0.0.{i + 1}", login: "target", minutesAgo: i);
@@ -84,11 +85,11 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_ManyIpsPerActor_Triggers()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
Guid actor = Guid.NewGuid();
for (int i = 0; i < SuspiciousActivityService.DistinctIpsPerActorThreshold; i++)
{
await store.AppendAsync(
await store.Store.AppendAsync(
new AuditRecordDto(
AuditEvents.TenantLoginOk,
AuditActorTypes.Tenant,
@@ -113,10 +114,10 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_AuthFailuresPerTenant_Triggers()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.AuthFailuresPerTenantThreshold; i++)
{
await store.AppendAsync(
await store.Store.AppendAsync(
new AuditRecordDto(
AuditEvents.TenantLoginFailed,
AuditActorTypes.Tenant,
@@ -140,7 +141,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_TriggersAtThreshold()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++)
{
SeedFailed(store, ip: "10.3.0.1", login: $"user{i}", minutesAgo: i);
@@ -160,7 +161,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_DoubleThreshold_IsHigh()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
int count = SuspiciousActivityService.DistinctLoginsPerIpThreshold * 2;
for (int i = 0; i < count; i++)
{
@@ -180,7 +181,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_BelowThreshold_NoFinding()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold - 1; i++)
{
SeedFailed(store, ip: "10.3.0.3", login: $"user{i}", minutesAgo: i);
@@ -195,7 +196,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_SameLoginRepeated_NoFinding()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold + 1; i++)
{
SeedFailed(store, ip: "10.3.0.4", login: "repeated", minutesAgo: i);
@@ -210,7 +211,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_SuccessfulLoginsIgnored()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++)
{
SeedSuccess(store, ip: "10.3.0.5", login: $"user{i}", minutesAgo: i);
@@ -225,7 +226,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_RecordsOutsideWindow_AreIgnored()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
for (int i = 0; i < 50; i++)
{
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(
FakeAuditLogStore store,
TestAuditLogStore store,
string ip,
string login,
int minutesAgo)
{
store.AppendAsync(
store.Store.AppendAsync(
new AuditRecordDto(
AuditEvents.TenantLoginFailed,
AuditActorTypes.Tenant,
@@ -258,12 +259,12 @@ public sealed class SuspiciousActivityServiceTests
// Пишет запись «успешный вход» с заданным временем.
private static void SeedSuccess(
FakeAuditLogStore store,
TestAuditLogStore store,
string ip,
string login,
int minutesAgo)
{
store.AppendAsync(
store.Store.AppendAsync(
new AuditRecordDto(
AuditEvents.TenantLoginOk,
AuditActorTypes.Tenant,
@@ -275,5 +276,5 @@ public sealed class SuspiciousActivityServiceTests
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 tenantStore = new TestTenantStore();
var provisioner = new TestTenantProvisioner();
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await RunAsync(
inviteStore, tenantStore, provisioner.Provisioner, authStore, auditStore,
@@ -83,7 +83,7 @@ public sealed class JoinEndpointHttpTests
var authStore = new FakeAuthStore();
await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(),
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(),
async (baseAddress, client) =>
{
using (HttpResponseMessage first = await PostJsonAsync(
@@ -111,7 +111,7 @@ public sealed class JoinEndpointHttpTests
var authStore = new FakeAuthStore();
await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(),
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(),
async (baseAddress, client) =>
{
using HttpResponseMessage response = await PostJsonAsync(
@@ -132,7 +132,7 @@ public sealed class JoinEndpointHttpTests
inviteStore.AddInvite(NewInvite(Email, status: InviteStatuses.Revoked));
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) =>
{
using HttpResponseMessage response = await PostJsonAsync(
@@ -150,7 +150,7 @@ public sealed class JoinEndpointHttpTests
inviteStore.AddInvite(NewInvite(Email, expiresAt: DateTimeOffset.UtcNow.AddHours(-1)));
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) =>
{
using HttpResponseMessage response = await PostJsonAsync(
@@ -165,7 +165,7 @@ public sealed class JoinEndpointHttpTests
public async Task Join_WithUnknownCode_Returns400NotFound()
{
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) =>
{
using HttpResponseMessage response = await PostJsonAsync(
@@ -183,7 +183,7 @@ public sealed class JoinEndpointHttpTests
inviteStore.AddInvite(NewInvite(Email));
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) =>
{
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"));
await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(),
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(),
async (baseAddress, client) =>
{
using HttpResponseMessage response = await PostJsonAsync(
@@ -231,7 +231,7 @@ public sealed class JoinEndpointHttpTests
var authStore = new FakeAuthStore();
await RunAsync(
inviteStore, tenantStore, provisioner.Provisioner, authStore, new FakeAuditLogStore(),
inviteStore, tenantStore, provisioner.Provisioner, authStore, new TestAuditLogStore(),
async (baseAddress, client) =>
{
using HttpResponseMessage response = await PostJsonAsync(
@@ -253,7 +253,7 @@ public sealed class JoinEndpointHttpTests
TestTenantStore tenantStore,
ITenantProvisioner provisioner,
FakeAuthStore authStore,
FakeAuditLogStore auditStore,
TestAuditLogStore auditStore,
Func<string, HttpClient, Task> scenario)
{
int port = TestPort.Allocate();
@@ -265,7 +265,7 @@ public sealed class JoinEndpointHttpTests
// последняя регистрация (зеркало OperatorAuthHttpHost).
builder.Services.AddSingleton(TestHashers.New());
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<ITenantRepository>(tenantStore.Repository);
builder.Services.AddSingleton<ITenantProvisioner>(provisioner);
@@ -43,7 +43,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
[Fact]
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.TenantLogout, AuditActorTypes.Tenant, ActiveTenant);
await SeedAuditAsync(auditStore, AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, ActiveTenant);
@@ -83,10 +83,10 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
[Fact]
public async Task Suspicious_ReturnsFindingsForFailedLoginBurst()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++)
{
await auditStore.AppendAsync(
await auditStore.Store.AppendAsync(
new AuditRecordDto(
AuditEvents.TenantLoginFailed,
AuditActorTypes.Tenant,
@@ -179,7 +179,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
[Fact]
public async Task Activity_FiltersByActorIdAndPaginates()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
Guid actor = Guid.NewGuid();
await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30);
await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20);
@@ -222,7 +222,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
[Fact]
public async Task Audit_FiltersByActorIdAndOffset()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
Guid actor = Guid.NewGuid();
await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30);
await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20);
@@ -275,13 +275,13 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
}
private static Task SeedAuditAsync(
FakeAuditLogStore store,
TestAuditLogStore store,
string eventType,
string actorType,
Guid? tenantId,
Guid? actorId = null,
int minutesAgo = 1) =>
store.AppendAsync(
store.Store.AppendAsync(
new AuditRecordDto(
eventType,
actorType,
@@ -39,7 +39,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact]
public async Task OperatorLoginSuccess_WritesOperatorLoginOkAuditRecord()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(),
@@ -65,7 +65,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact]
public async Task OperatorLoginFailure_WritesOperatorLoginFailedAuditRecord()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(),
@@ -91,7 +91,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact]
public async Task TenantLoginSuccessAndFailure_WriteTenantAuditRecords()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(),
@@ -137,9 +137,9 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact]
public async Task Audit_ReturnsItemsNewestFirstWithTotal()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
// Историческая запись со старым At — гарантированно последняя в выборке (проверка At DESC).
await auditStore.AppendAsync(new AuditRecordDto(
await auditStore.Store.AppendAsync(new AuditRecordDto(
AuditEvents.TenantCreated,
AuditActorTypes.Operator,
ActorId: Guid.NewGuid(),
@@ -177,7 +177,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact]
public async Task Audit_FiltersByEventTypeAndActorType()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(),
@@ -218,7 +218,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact]
public async Task TenantLogout_WritesTenantLogoutAuditRecord()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(),
@@ -251,7 +251,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact]
public async Task OperatorLogout_WritesOperatorLogoutAuditRecord()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(),
@@ -43,7 +43,7 @@ internal static class OperatorAuthHttpHost
FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, Task> scenario,
FakeAuditLogStore? auditStore = null) =>
TestAuditLogStore? auditStore = null) =>
await RunCoreAsync(
operatorStore,
userStore,
@@ -64,8 +64,8 @@ internal static class OperatorAuthHttpHost
public static async Task RunAsync(
FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestInviteStore, FakeAuditLogStore, Task> scenario,
FakeAuditLogStore? auditStore = null,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestInviteStore, TestAuditLogStore, Task> scenario,
TestAuditLogStore? auditStore = null,
TestInviteStore? inviteStore = null) =>
await RunCoreAsync(
operatorStore,
@@ -88,8 +88,8 @@ internal static class OperatorAuthHttpHost
public static async Task RunAsync(
FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, Task> scenario,
FakeAuditLogStore? auditStore = null,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, Task> scenario,
TestAuditLogStore? auditStore = null,
TestInviteStore? inviteStore = null,
TestTenantStore? tenantStore = null) =>
await RunCoreAsync(
@@ -115,8 +115,8 @@ internal static class OperatorAuthHttpHost
public static async Task RunAsync(
FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario,
FakeAuditLogStore? auditStore = null,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, FakeTenantLimitStore, Task> scenario,
TestAuditLogStore? auditStore = null,
TestInviteStore? inviteStore = null,
TestTenantStore? tenantStore = null,
FakeTenantLimitStore? limitStore = null,
@@ -144,8 +144,8 @@ internal static class OperatorAuthHttpHost
public static async Task RunWithGlobalSettingsAsync(
FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestGlobalSettingsStore, FakeAuditLogStore, Task> scenario,
FakeAuditLogStore? auditStore = null,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestGlobalSettingsStore, TestAuditLogStore, Task> scenario,
TestAuditLogStore? auditStore = null,
TestGlobalSettingsStore? globalSettingsStore = null) =>
await RunCoreAsync(
operatorStore,
@@ -161,16 +161,16 @@ internal static class OperatorAuthHttpHost
private static async Task RunCoreAsync(
FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore,
FakeAuditLogStore? auditStore,
TestAuditLogStore? auditStore,
TestInviteStore? inviteStore,
TestTenantStore? tenantStore,
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,
TestTokenUsageEventStore? tokenUsageStore = null,
TestGlobalSettingsStore? globalSettingsStore = null)
{
FakeAuditLogStore effectiveAuditStore = auditStore ?? new FakeAuditLogStore();
TestAuditLogStore effectiveAuditStore = auditStore ?? new TestAuditLogStore();
TestInviteStore effectiveInviteStore = inviteStore ?? new TestInviteStore();
TestTenantStore effectiveTenantStore = tenantStore ?? new TestTenantStore();
FakeTenantLimitStore effectiveLimitStore = limitStore ?? new FakeTenantLimitStore();
@@ -188,7 +188,7 @@ internal static class OperatorAuthHttpHost
builder.Services.AddSingleton<ITenantContext, TenantContext>();
builder.Services.AddSingleton<IAuthStore>(userStore);
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<ITenantRepository>(effectiveTenantStore.Repository);
builder.Services.AddSingleton<ITenantLimitStore>(effectiveLimitStore);
@@ -92,7 +92,7 @@ public sealed class OperatorHealthEndpointsHttpTests
}
// Прогоняет сценарий на хосте с активным оператором 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);
// Фейк-хранилище оператора с активным оператором operator/operator.
@@ -70,7 +70,7 @@ public sealed class OperatorInvitesEndpointsHttpTests
[Fact]
public async Task CreateThenListThenRevoke_FullOperatorFlow_WorksAndWritesAudit()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(),
@@ -120,7 +120,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
[Fact]
public async Task PatchLimit_ResetsFlags_UpdatesBudgetAndWritesAudit()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
const long newBudget = 20_000_000;
await RunAsync(
@@ -165,7 +165,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
[Fact]
public async Task PatchLimit_OnlyPeriod_KeepsBudgetAndWritesAudit()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await RunAsync(
async (baseAddress, operatorStore, _, _, _, _, _) =>
@@ -195,7 +195,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
[Fact]
public async Task PatchLimit_WithSameValues_IsIdempotent_WithoutNewAudit()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
const long budget = 25_000_000;
await RunAsync(
@@ -284,7 +284,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
$"{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;
// второй: небольшой бюджет без расхода. Дата начала периода — текущая (reset не срабатывает).
@@ -72,7 +72,7 @@ public sealed class OperatorMaintenanceEndpointsHttpTests
}
// Прогоняет сценарий на хосте с активным оператором 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);
// Фейк-хранилище оператора с активным оператором operator/operator.
@@ -61,7 +61,7 @@ public sealed class OperatorSettingsEndpointsHttpTests
[Fact]
public async Task PutTelegramKeys_SavesEncryptedKeysMasksResponseAndWritesAudit()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await RunAsync(
async (baseAddress, operatorStore, _, globalSettings, audit) =>
@@ -257,7 +257,7 @@ public sealed class OperatorSettingsEndpointsHttpTests
$"{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(
NewOperatorStore(),
new FakeAuthStore(),
@@ -97,7 +97,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
[Fact]
public async Task Create_ReturnsCreatedTenant_AndWritesTenantCreatedAudit()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await RunAsync(
async (baseAddress, operatorStore, _, tenantStore, _) =>
@@ -138,7 +138,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
[Fact]
public async Task Create_WithOwnerEmail_ReturnsOneTimePasswordAndCreatesOwner()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
const string ownerEmail = "owner@created.com";
await RunAsync(
@@ -237,7 +237,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
[Fact]
public async Task Suspend_BlocksTenantLogin_ThenUnsuspend_RestoresLogin()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await RunAsync(
async (baseAddress, operatorStore, userStore, tenantStore, _) =>
@@ -300,7 +300,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
[Fact]
public async Task Suspend_AlreadySuspendedTenant_IsOkWithoutDuplicateAudit()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
var tenantStore = new TestTenantStore(
new TenantRecordDto(TenantId, TenantName, TenantStatuses.Suspended, DateTimeOffset.UtcNow));
@@ -342,7 +342,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
[Fact]
public async Task Impersonate_ReturnsSessionToken_WorksAsDealSession_AndLogoutWritesStoppedAudit()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await RunAsync(
async (baseAddress, operatorStore, userStore, _, _) =>
@@ -486,8 +486,8 @@ public sealed class OperatorTenantsEndpointsHttpTests
// Прогоняет сценарий на хосте с одним активным тенантом и одним пользователем в нём.
private static Task RunAsync(
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, FakeAuditLogStore, Task> scenario,
FakeAuditLogStore? auditStore = null,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestAuditLogStore, Task> scenario,
TestAuditLogStore? auditStore = null,
TestTenantStore? tenantStore = null)
{
// По умолчанию в реестре — активный целевой тенант (пользователь в нём — NewUserStore).
@@ -503,7 +503,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
// Проверяет запись tenant_status_changed с заданным статусом и актором-оператором.
private static void AssertStatusChangedAudit(
FakeAuditLogStore auditStore,
TestAuditLogStore auditStore,
FakeOperatorAuthStore operatorStore,
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));
}