Обновить тесты аудита под контракт изменений
HTTP-тесты аудита и лимитов проверяют changes вместо плоского detailJson; подставки реестра умеют FindNamesByIdsAsync. Добавлены тесты разбора деталей (новый формат, старый плоский, повреждённый JSON) и проверка ответа аудита с именем пользователя и изменениями.
This commit is contained in:
@@ -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 =>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user