Обновить тесты аудита под контракт изменений

HTTP-тесты аудита и лимитов проверяют changes вместо плоского detailJson; подставки реестра умеют FindNamesByIdsAsync. Добавлены тесты разбора деталей (новый формат, старый плоский, повреждённый JSON) и проверка ответа аудита с именем пользователя и изменениями.
This commit is contained in:
2026-09-13 19:18:16 +03:00
parent ad5fa7c083
commit 4a6fa374c5
13 changed files with 183 additions and 59 deletions
@@ -0,0 +1,95 @@
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// Юнит-тесты разбора деталей аудита
/// </summary>
public sealed class AuditRecordDtoExtensionsTests
{
[Fact]
public void AuditChanges_NewShape_ReturnsFieldFromTo()
{
var record = Record(
"""{"changes":[{"field":"status","from":"active","to":"suspended"}]}""");
AuditChangeDto change = Assert.Single(record.AuditChanges());
Assert.Equal("status", change.Field);
Assert.Equal("active", change.From);
Assert.Equal("suspended", change.To);
}
[Fact]
public void AuditChanges_NewShape_KeepsAbsentFromAsNull()
{
var record = Record("""{"changes":[{"field":"login","to":"admin"}]}""");
AuditChangeDto change = Assert.Single(record.AuditChanges());
Assert.Equal("login", change.Field);
Assert.Null(change.From);
Assert.Equal("admin", change.To);
}
[Fact]
public void AuditChanges_LegacyFlat_PairsOldNewAndDropsTenantId()
{
var record = Record(
"""{"tenantId":"6f3c0d1e-2b4a-4c8d-9e0f-1a2b3c4d5e6f","oldBudget":100,"newBudget":200,"name":"ten"}""");
IReadOnlyList<AuditChangeDto> changes = record.AuditChanges();
Assert.Collection(
changes,
budget =>
{
Assert.Equal("budget", budget.Field);
Assert.Equal("100", budget.From);
Assert.Equal("200", budget.To);
},
name =>
{
Assert.Equal("name", name.Field);
Assert.Null(name.From);
Assert.Equal("ten", name.To);
});
}
[Fact]
public void AuditChanges_InvalidJson_ReturnsEmpty()
{
var record = Record("{ broken");
Assert.Empty(record.AuditChanges());
}
[Fact]
public void AuditChanges_NoDetails_ReturnsEmpty()
{
var record = Record(null);
Assert.Empty(record.AuditChanges());
}
[Fact]
public void DetailValue_ReturnsToThenFrom()
{
var setRecord = Record(AuditService.ToDetailJson([AuditDetails.Set("login", "admin")]));
var changeRecord = Record(AuditService.ToDetailJson([AuditDetails.Change("login", "old", "new")]));
Assert.Equal("admin", setRecord.DetailValue("login"));
Assert.Equal("new", changeRecord.DetailValue("login"));
Assert.Null(setRecord.DetailValue("missing"));
}
private static AuditRecordDto Record(string? detailJson) =>
new(
AuditEvents.TenantLoginOk,
AuditActorTypes.Tenant,
ActorId: null,
TenantId: null,
Ip: null,
DetailJson: detailJson);
}
@@ -109,11 +109,12 @@ public sealed class AuditServiceTests
}
[Fact]
public void ToDetailJson_SerializesCamelCase()
public void ToDetailJson_SerializesChangeFields()
{
string json = AuditService.ToDetailJson(new { login = "Admin" });
string json = AuditService.ToDetailJson([AuditDetails.Set("login", "Admin")]);
Assert.Contains("\"login\":\"Admin\"", json);
Assert.Contains("\"field\":\"login\"", json);
Assert.Contains("\"to\":\"Admin\"", json);
}
[Fact]
@@ -96,7 +96,7 @@ public sealed class SuspiciousActivityServiceTests
ActorId: actor,
TenantId: Tenant,
Ip: $"10.1.0.{i + 1}",
DetailJson: AuditService.ToDetailJson(new { login = "user" }),
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", "user")]),
At: Now.AddMinutes(-i)),
CancellationToken.None);
}
@@ -124,7 +124,7 @@ public sealed class SuspiciousActivityServiceTests
ActorId: null,
TenantId: Tenant,
Ip: $"10.2.0.{i + 1}",
DetailJson: AuditService.ToDetailJson(new { login = $"user{i}" }),
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", $"user{i}")]),
At: Now.AddMinutes(-i)),
CancellationToken.None);
}
@@ -252,7 +252,7 @@ public sealed class SuspiciousActivityServiceTests
ActorId: null,
TenantId: null,
Ip: ip,
DetailJson: AuditService.ToDetailJson(new { login }),
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", login)]),
At: Now.AddMinutes(-minutesAgo)),
CancellationToken.None);
}
@@ -271,7 +271,7 @@ public sealed class SuspiciousActivityServiceTests
ActorId: null,
TenantId: null,
Ip: ip,
DetailJson: AuditService.ToDetailJson(new { login }),
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", login)]),
At: Now.AddMinutes(-minutesAgo)),
CancellationToken.None);
}
@@ -307,10 +307,8 @@ public sealed class JoinEndpointHttpTests
string code)
{
Assert.NotNull(record.DetailJson);
using var document = JsonDocument.Parse(record.DetailJson!);
JsonElement root = document.RootElement;
Assert.Equal(email, root.GetProperty("email").GetString());
Assert.Equal(SessionTokens.HashToken(code), root.GetProperty("codeHash").GetString());
Assert.Equal(email, record.DetailValue("email"));
Assert.Equal(SessionTokens.HashToken(code), record.DetailValue("codeHash"));
}
@@ -93,7 +93,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
ActorId: null,
TenantId: ActiveTenant,
Ip: "10.9.0.1",
DetailJson: AuditService.ToDetailJson(new { login = "brute" }),
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", "brute")]),
At: DateTimeOffset.UtcNow.AddMinutes(-i)),
CancellationToken.None);
}
@@ -2,6 +2,7 @@ using System.Net;
using System.Text;
using System.Text.Json;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
@@ -276,6 +277,46 @@ public sealed class OperatorAuditEndpointsHttpTests
auditStore);
}
[Fact]
public async Task Audit_ReturnsTenantNameAndHumanReadableChanges()
{
var tenantId = Guid.NewGuid();
var auditStore = new TestAuditLogStore();
await auditStore.Store.AppendAsync(
new AuditRecordDto(
AuditEvents.TenantStatusChanged,
AuditActorTypes.Operator,
ActorId: Guid.NewGuid(),
TenantId: tenantId,
Ip: null,
DetailJson: AuditService.ToDetailJson([AuditDetails.Change("status", "active", "suspended")]),
At: DateTimeOffset.UtcNow),
CancellationToken.None);
await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(),
NewUserStore(),
async (baseAddress, _, _, _, _, _) =>
{
HttpClient operatorClient = CreateClient(baseAddress);
await LoginOperatorAsync(operatorClient, baseAddress);
using HttpResponseMessage response = await operatorClient.GetAsync($"{baseAddress}/api/operator/audit?eventType=tenant_status_changed");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
JsonElement item = (await ReadJsonAsync(response)).GetProperty("items")[0];
Assert.Equal(tenantId, item.GetProperty("tenantId").GetGuid());
Assert.Equal("acme", item.GetProperty("tenantName").GetString());
JsonElement change = item.GetProperty("changes")[0];
Assert.Equal("status", change.GetProperty("field").GetString());
Assert.Equal("active", change.GetProperty("from").GetString());
Assert.Equal("suspended", change.GetProperty("to").GetString());
},
auditStore,
tenantStore: new TestTenantStore(
new TenantRecordDto(tenantId, "acme", TenantStatuses.Active, DateTimeOffset.UtcNow)));
}
// ─── Хелперы ─────────────────────────────────────────────────────────
// Фейк-хранилище оператора с активным operator/operator.
@@ -346,10 +387,11 @@ public sealed class OperatorAuditEndpointsHttpTests
Assert.Contains(IpPrefix, record.Ip);
}
// Проверяет login попытки в DetailJson записи (без пароля).
// Проверяет логин попытки в деталях записи (без пароля).
private static void AssertDetailLogin(AuditRecordDto record, string login)
{
Assert.NotNull(record.DetailJson);
Assert.Contains($"\"login\":\"{login}\"", record.DetailJson);
Assert.Contains("\"field\":\"login\"", record.DetailJson);
Assert.Contains($"\"to\":\"{login}\"", record.DetailJson);
}
}
@@ -247,10 +247,8 @@ public sealed class OperatorInvitesEndpointsHttpTests
string code)
{
Assert.NotNull(record.DetailJson);
using var document = JsonDocument.Parse(record.DetailJson!);
JsonElement root = document.RootElement;
Assert.Equal(email, root.GetProperty("email").GetString());
Assert.Equal(SessionTokens.HashToken(code), root.GetProperty("codeHash").GetString());
Assert.Equal(email, record.DetailValue("email"));
Assert.Equal(SessionTokens.HashToken(code), record.DetailValue("codeHash"));
}
// Логинит оператора (ожидается 200).
@@ -150,14 +150,12 @@ public sealed class OperatorLimitsEndpointsHttpTests
Assert.Equal(AuditActorTypes.Operator, audit.ActorType);
Assert.Equal(operatorStore.Operators.Single().Id, audit.ActorId);
Assert.Equal(FirstTenantId, audit.TenantId);
Assert.NotNull(audit.DetailJson);
using (var detail = JsonDocument.Parse(audit.DetailJson!))
{
Assert.Equal(DefaultBudgetTokens, detail.RootElement.GetProperty("oldBudget").GetInt64());
Assert.Equal(TenantLimitPeriods.Month, detail.RootElement.GetProperty("oldPeriod").GetString());
Assert.Equal(newBudget, detail.RootElement.GetProperty("budgetTokens").GetInt64());
Assert.Equal(TenantLimitPeriods.Month, detail.RootElement.GetProperty("period").GetString());
}
AuditChangeDto budget = audit.AuditChanges().Single(c => c.Field == "budget");
Assert.Equal(DefaultBudgetTokens.ToString(), budget.From);
Assert.Equal(newBudget.ToString(), budget.To);
AuditChangeDto period = audit.AuditChanges().Single(c => c.Field == "period");
Assert.Equal(TenantLimitPeriods.Month, period.From);
Assert.Equal(TenantLimitPeriods.Month, period.To);
},
auditStore);
}
@@ -182,12 +180,8 @@ public sealed class OperatorLimitsEndpointsHttpTests
AuditRecordDto audit = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.TenantLimitChanged);
Assert.Equal(operatorStore.Operators.Single().Id, audit.ActorId);
Assert.NotNull(audit.DetailJson);
using (var detail = JsonDocument.Parse(audit.DetailJson!))
{
Assert.Equal(DefaultBudgetTokens, detail.RootElement.GetProperty("budgetTokens").GetInt64());
Assert.Equal(TenantLimitPeriods.Day, detail.RootElement.GetProperty("period").GetString());
}
Assert.Equal(DefaultBudgetTokens.ToString(), audit.AuditChanges().Single(c => c.Field == "budget").To);
Assert.Equal(TenantLimitPeriods.Day, audit.AuditChanges().Single(c => c.Field == "period").To);
},
auditStore);
}
@@ -89,9 +89,8 @@ public sealed class OperatorSettingsEndpointsHttpTests
Assert.Null(record.TenantId);
Assert.NotNull(record.DetailJson);
Assert.DoesNotContain("abcdefghijklmnop", record.DetailJson);
using var detail = JsonDocument.Parse(record.DetailJson!);
Assert.Equal("1234567", detail.RootElement.GetProperty("apiId").GetString());
Assert.True(detail.RootElement.GetProperty("apiHashSet").GetBoolean());
Assert.Equal("1234567", record.DetailValue("apiId"));
Assert.Equal("true", record.DetailValue("apiHashSet"));
},
auditStore);
}
@@ -126,11 +126,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
Assert.Equal(operatorStore.Operators.Single().Id, createdAudit.ActorId);
Assert.Equal(createdId, createdAudit.TenantId);
Assert.NotNull(createdAudit.DetailJson);
using (var detail = JsonDocument.Parse(createdAudit.DetailJson!))
{
Assert.Equal(createdId, detail.RootElement.GetProperty("tenantId").GetGuid());
Assert.Equal(NewTenantName, detail.RootElement.GetProperty("name").GetString());
}
Assert.Equal(NewTenantName, createdAudit.DetailValue("name"));
},
auditStore);
}
@@ -367,11 +363,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
Assert.Equal(operatorStore.Operators.Single().Id, started.ActorId);
Assert.Equal(TenantId, started.TenantId);
Assert.NotNull(started.DetailJson);
using (var detail = JsonDocument.Parse(started.DetailJson!))
{
Assert.Equal(TenantUserLogin, detail.RootElement.GetProperty("targetLogin").GetString());
Assert.Equal(TenantId, detail.RootElement.GetProperty("tenantId").GetGuid());
}
Assert.Equal(TenantUserLogin, started.DetailValue("targetLogin"));
HttpClient userClient = CreateClient(baseAddress);
using (HttpResponseMessage me = await GetWithTenantCookieAsync(userClient, baseAddress, sessionToken, "/api/auth/me"))
@@ -514,26 +506,17 @@ public sealed class OperatorTenantsEndpointsHttpTests
Assert.Equal(AuditActorTypes.Operator, record.ActorType);
Assert.Equal(operatorStore.Operators.Single().Id, record.ActorId);
Assert.Equal(TenantId, record.TenantId);
Assert.NotNull(record.DetailJson);
using var detail = JsonDocument.Parse(record.DetailJson!);
Assert.Equal(TenantId, detail.RootElement.GetProperty("tenantId").GetGuid());
Assert.Equal(status, detail.RootElement.GetProperty("status").GetString());
Assert.Equal(status, record.DetailValue("status"));
}
// Статус из DetailJson записи tenant_status_changed.
private static string? StatusOf(AuditRecordDto record)
{
Assert.NotNull(record.DetailJson);
using var detail = JsonDocument.Parse(record.DetailJson!);
return detail.RootElement.GetProperty("status").GetString();
}
// Статус из деталей записи tenant_status_changed.
private static string? StatusOf(AuditRecordDto record) => record.DetailValue("status");
// Проверяет DetailJson записи аудита: поле login.
// Проверяет детали записи аудита: параметр login.
private static void AssertDetailLogin(AuditRecordDto record, string login)
{
Assert.NotNull(record.DetailJson);
using var detail = JsonDocument.Parse(record.DetailJson!);
Assert.Equal(login, detail.RootElement.GetProperty("login").GetString());
Assert.Equal(login, record.DetailValue("login"));
}
// GET с tenant-кукой deal_session=rawToken (impersonation-токен как значение куки).
@@ -457,6 +457,12 @@ public sealed class StorageTickSchedulerTests
CancellationToken ct) =>
throw new InvalidOperationException("реестр тенантов недоступен");
/// <inheritdoc />
public Task<IReadOnlyDictionary<Guid, string>> FindNamesByIdsAsync(
IReadOnlyCollection<Guid> ids,
CancellationToken ct) =>
throw new InvalidOperationException("реестр тенантов недоступен");
/// <inheritdoc />
public Task<TenantRecordDto?> FindByIdAsync(Guid id, CancellationToken ct) =>
throw new NotSupportedException();
@@ -38,6 +38,10 @@ public sealed class TestTenantRepository
.Do(ci => _pageRequests.Add((ci.ArgAt<int>(0), ci.ArgAt<int>(1))));
Repository.ListPageAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => (IReadOnlyList<TenantRecordDto>)_tenants.Skip(ci.ArgAt<int>(0)).Take(ci.ArgAt<int>(1)).ToList());
Repository.FindNamesByIdsAsync(Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<CancellationToken>())
.Returns(ci => (IReadOnlyDictionary<Guid, string>)_tenants
.Where(tenant => ci.ArgAt<IReadOnlyCollection<Guid>>(0).Contains(tenant.Id))
.ToDictionary(tenant => tenant.Id, tenant => tenant.Name));
Repository.FindByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns<TenantRecordDto?>(_ => throw new NotSupportedException("FindByIdAsync не используется тестами реестра"));
Repository.UpdateStatusAsync(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
@@ -39,6 +39,10 @@ public sealed class TestTenantStore
.Returns(_tenants);
Repository.ListPageAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => (IReadOnlyList<TenantRecordDto>)_tenants.Skip(ci.ArgAt<int>(0)).Take(ci.ArgAt<int>(1)).ToList());
Repository.FindNamesByIdsAsync(Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<CancellationToken>())
.Returns(ci => (IReadOnlyDictionary<Guid, string>)_tenants
.Where(tenant => ci.ArgAt<IReadOnlyCollection<Guid>>(0).Contains(tenant.Id))
.ToDictionary(tenant => tenant.Id, tenant => tenant.Name));
Repository.UpdateStatusAsync(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{