diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditRecordDtoExtensionsTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditRecordDtoExtensionsTests.cs
new file mode 100644
index 0000000..60790d4
--- /dev/null
+++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditRecordDtoExtensionsTests.cs
@@ -0,0 +1,95 @@
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Services;
+
+namespace Deal.Tests.Unit.Modules.Tenants;
+
+///
+/// Юнит-тесты разбора деталей аудита
+///
+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 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);
+}
diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditServiceTests.cs
index 2d01bf0..e76bec2 100644
--- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditServiceTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditServiceTests.cs
@@ -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]
diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SuspiciousActivityServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SuspiciousActivityServiceTests.cs
index c54c787..5c8cfc5 100644
--- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SuspiciousActivityServiceTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SuspiciousActivityServiceTests.cs
@@ -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);
}
diff --git a/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs
index 220e1cf..34073ac 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs
@@ -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"));
}
diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAnalyticsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAnalyticsEndpointsHttpTests.cs
index d4379b6..909fac6 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAnalyticsEndpointsHttpTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAnalyticsEndpointsHttpTests.cs
@@ -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);
}
diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuditEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuditEndpointsHttpTests.cs
index 4c80ebe..3fdef10 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuditEndpointsHttpTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuditEndpointsHttpTests.cs
@@ -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);
}
}
diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorInvitesEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorInvitesEndpointsHttpTests.cs
index 4c99114..fb01b52 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/OperatorInvitesEndpointsHttpTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorInvitesEndpointsHttpTests.cs
@@ -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).
diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs
index 7bee4dd..738cb62 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs
@@ -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);
}
diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorSettingsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorSettingsEndpointsHttpTests.cs
index 4280c87..c099a53 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/OperatorSettingsEndpointsHttpTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorSettingsEndpointsHttpTests.cs
@@ -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);
}
diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorTenantsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorTenantsEndpointsHttpTests.cs
index 5579f7f..08741a4 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/OperatorTenantsEndpointsHttpTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorTenantsEndpointsHttpTests.cs
@@ -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-токен как значение куки).
diff --git a/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs
index 95198d0..5f54613 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs
@@ -457,6 +457,12 @@ public sealed class StorageTickSchedulerTests
CancellationToken ct) =>
throw new InvalidOperationException("реестр тенантов недоступен");
+ ///
+ public Task> FindNamesByIdsAsync(
+ IReadOnlyCollection ids,
+ CancellationToken ct) =>
+ throw new InvalidOperationException("реестр тенантов недоступен");
+
///
public Task FindByIdAsync(Guid id, CancellationToken ct) =>
throw new NotSupportedException();
diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestTenantRepository.cs b/src/core/tests/Deal.Tests.Unit/Support/TestTenantRepository.cs
index 42a5c99..6ca16af 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/TestTenantRepository.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/TestTenantRepository.cs
@@ -38,6 +38,10 @@ public sealed class TestTenantRepository
.Do(ci => _pageRequests.Add((ci.ArgAt(0), ci.ArgAt(1))));
Repository.ListPageAsync(Arg.Any(), Arg.Any(), Arg.Any())
.Returns(ci => (IReadOnlyList)_tenants.Skip(ci.ArgAt(0)).Take(ci.ArgAt(1)).ToList());
+ Repository.FindNamesByIdsAsync(Arg.Any>(), Arg.Any())
+ .Returns(ci => (IReadOnlyDictionary)_tenants
+ .Where(tenant => ci.ArgAt>(0).Contains(tenant.Id))
+ .ToDictionary(tenant => tenant.Id, tenant => tenant.Name));
Repository.FindByIdAsync(Arg.Any(), Arg.Any())
.Returns(_ => throw new NotSupportedException("FindByIdAsync не используется тестами реестра"));
Repository.UpdateStatusAsync(Arg.Any(), Arg.Any(), Arg.Any())
diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestTenantStore.cs b/src/core/tests/Deal.Tests.Unit/Support/TestTenantStore.cs
index 2925ec6..7a59059 100644
--- a/src/core/tests/Deal.Tests.Unit/Support/TestTenantStore.cs
+++ b/src/core/tests/Deal.Tests.Unit/Support/TestTenantStore.cs
@@ -39,6 +39,10 @@ public sealed class TestTenantStore
.Returns(_tenants);
Repository.ListPageAsync(Arg.Any(), Arg.Any(), Arg.Any())
.Returns(ci => (IReadOnlyList)_tenants.Skip(ci.ArgAt(0)).Take(ci.ArgAt(1)).ToList());
+ Repository.FindNamesByIdsAsync(Arg.Any>(), Arg.Any())
+ .Returns(ci => (IReadOnlyDictionary)_tenants
+ .Where(tenant => ci.ArgAt>(0).Contains(tenant.Id))
+ .ToDictionary(tenant => tenant.Id, tenant => tenant.Name));
Repository.UpdateStatusAsync(Arg.Any(), Arg.Any(), Arg.Any())
.Returns(ci =>
{