food-market/tests/food-market.IntegrationTests/OrgAuditLogTests.cs
nns a189a5dd6e feat(audit): per-tenant журнал мутаций OrgAuditLog (P1-18)
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>
2026-05-28 16:26:36 +05:00

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();
}
}