Domain OrgAuditLog (TenantEntity) - per-org журнал create/update/delete
для Supply/SupplierReturn/RetailSale/Demand/Product/ProductPrice/
ProductBarcode/Counterparty (белый список в IsTracked).
Реализация: OrgAuditInterceptor (SaveChangesInterceptor) снимает diff на
SavingChanges (до commit), пишет в тот же DbContext в той же транзакции -
атомарно с самой мутацией. ChangesJson формата
{ "field": { "before": X, "after": Y } } - служебные поля
(OrganizationId/CreatedAt/UpdatedAt) пропускаются.
ITenantContext получил UserId (sub claim) для атрибуции событий.
AppDbContext.SkipAudit - escape-hatch для сидеров/системных операций.
Tenant-isolation: query-filter обычный TenantEntity-фильтр. B не видит
audit-строки A; SuperAdmin без override видит всё.
Контроллер GET /api/admin/audit-log с фильтрами entityType / entityId /
userId / action / from / to. Permission OrgSettingsManage.
Web: /audit-log для Admin'а - таблица с раскрывающимся JSON diff'ом,
цветные плашки create/update/delete, фильтры по типу и действию.
Миграция Phase8b_OrgAuditLog: jsonb-колонка, индексы
(OrgId+CreatedAt), (OrgId+EntityType+EntityId), (OrgId+UserId+CreatedAt).
Тесты: 3 интеграционных (create Product создаёт audit-запись;
update Counterparty - diff содержит before/after; tenant-изоляция:
B не видит записи A).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
84 lines
3.7 KiB
C#
84 lines
3.7 KiB
C#
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using FluentAssertions;
|
|
using foodmarket.IntegrationTests.Support;
|
|
using Xunit;
|
|
|
|
namespace foodmarket.IntegrationTests;
|
|
|
|
[Collection(ApiCollection.Name)]
|
|
public class OrgAuditLogTests
|
|
{
|
|
private readonly ApiFactory _factory;
|
|
public OrgAuditLogTests(ApiFactory factory) => _factory = factory;
|
|
private static string RandomBarcode()
|
|
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
|
|
|
|
[Fact]
|
|
public async Task Product_create_writes_audit_entry()
|
|
{
|
|
var api = new ApiActor(_factory.CreateClient());
|
|
await api.SignupAndLoginAsync($"audit-{Guid.NewGuid():N}");
|
|
var refs = await api.LoadRefsAsync();
|
|
|
|
var pid = await api.CreateProductAsync(refs, $"AUD-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
|
|
|
var log = await api.GetJsonAsync("/api/admin/audit-log?entityType=Product&pageSize=20");
|
|
var items = log.GetProperty("items").EnumerateArray().ToList();
|
|
var entry = items.First(x => x.GetProperty("entityId").GetString() == pid);
|
|
entry.GetProperty("action").GetString().Should().Be("create");
|
|
entry.GetProperty("entityType").GetString().Should().Be("Product");
|
|
entry.GetProperty("changesJson").GetString().Should().Contain("\"after\"");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Counterparty_update_diff_contains_before_after()
|
|
{
|
|
var api = new ApiActor(_factory.CreateClient());
|
|
await api.SignupAndLoginAsync($"audit-upd-{Guid.NewGuid():N}");
|
|
var cid = await api.CreateCounterpartyAsync($"Init-{Guid.NewGuid():N}");
|
|
|
|
// Обновляем — меняем имя.
|
|
var newName = $"Renamed-{Guid.NewGuid():N}";
|
|
var resp = await api.Http.PutAsJsonAsync($"/api/catalog/counterparties/{cid}", new
|
|
{
|
|
name = newName, legalName = (string?)null, type = 0,
|
|
bin = "987654321098", iin = (string?)null, taxNumber = (string?)null,
|
|
countryId = (string?)null, address = "Алматы", phone = "+77000000000",
|
|
email = "x@y.kz", bankName = (string?)null, bankAccount = (string?)null,
|
|
bik = (string?)null, contactPerson = "X", notes = (string?)null,
|
|
});
|
|
resp.EnsureSuccessStatusCode();
|
|
|
|
var log = await api.GetJsonAsync($"/api/admin/audit-log?entityType=Counterparty&entityId={cid}");
|
|
var items = log.GetProperty("items").EnumerateArray().ToList();
|
|
items.Should().Contain(x => x.GetProperty("action").GetString() == "update");
|
|
var update = items.First(x => x.GetProperty("action").GetString() == "update");
|
|
var diff = update.GetProperty("changesJson").GetString()!;
|
|
diff.Should().Contain("\"before\"");
|
|
diff.Should().Contain("\"after\"");
|
|
diff.Should().Contain(newName);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Tenant_isolation_audit_log()
|
|
{
|
|
var a = new ApiActor(_factory.CreateClient());
|
|
var b = new ApiActor(_factory.CreateClient());
|
|
await a.SignupAndLoginAsync($"audit-iso-a-{Guid.NewGuid():N}");
|
|
await b.SignupAndLoginAsync($"audit-iso-b-{Guid.NewGuid():N}");
|
|
var refsA = await a.LoadRefsAsync();
|
|
|
|
var pidA = await a.CreateProductAsync(refsA, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
|
|
|
// A видит свою запись.
|
|
var logA = await a.GetJsonAsync($"/api/admin/audit-log?entityId={pidA}");
|
|
logA.GetProperty("items").EnumerateArray().Should().Contain(
|
|
x => x.GetProperty("entityId").GetString() == pidA);
|
|
|
|
// B не видит чужую запись (query-filter по OrganizationId).
|
|
var logB = await b.GetJsonAsync($"/api/admin/audit-log?entityId={pidA}");
|
|
logB.GetProperty("items").EnumerateArray().Should().BeEmpty();
|
|
}
|
|
}
|