diff --git a/src/food-market.api/Controllers/Admin/OrgAuditLogController.cs b/src/food-market.api/Controllers/Admin/OrgAuditLogController.cs new file mode 100644 index 0000000..7d2b4d6 --- /dev/null +++ b/src/food-market.api/Controllers/Admin/OrgAuditLogController.cs @@ -0,0 +1,61 @@ +using foodmarket.Application.Common; +using foodmarket.Infrastructure.Persistence; +using foodmarket.Api.Infrastructure.Authorization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Admin; + +/// Чтение per-tenant журнала мутаций. Запись — автоматическая через +/// OrgAuditInterceptor; этот контроллер только показывает. Доступ — +/// admin'у организации (через permission OrgSettingsManage). SuperAdmin +/// видит всё (через query-filter bypass + IsSuperAdmin). +[ApiController] +[Authorize] +[Route("api/admin/audit-log")] +public class OrgAuditLogController : ControllerBase +{ + private readonly AppDbContext _db; + public OrgAuditLogController(AppDbContext db) => _db = db; + + public record AuditRow( + Guid Id, DateTime CreatedAt, + Guid? UserId, string? UserName, + string Action, string EntityType, Guid? EntityId, + string ChangesJson); + + [HttpGet, RequiresPermission("OrgSettingsManage")] + public async Task>> List( + [FromQuery] PagedRequest req, + [FromQuery] string? entityType, + [FromQuery] Guid? entityId, + [FromQuery] Guid? userId, + [FromQuery] string? action, + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + CancellationToken ct) + { + var q = _db.OrgAuditLogs.AsNoTracking().AsQueryable(); + if (!string.IsNullOrWhiteSpace(entityType)) q = q.Where(l => l.EntityType == entityType); + if (entityId is not null) q = q.Where(l => l.EntityId == entityId); + if (userId is not null) q = q.Where(l => l.UserId == userId); + if (!string.IsNullOrWhiteSpace(action)) q = q.Where(l => l.Action == action); + if (from is not null) q = q.Where(l => l.CreatedAt >= from); + if (to is not null) q = q.Where(l => l.CreatedAt <= to); + + var total = await q.CountAsync(ct); + var items = await (from l in q.OrderByDescending(l => l.CreatedAt) + .Skip(req.Skip).Take(req.Take) + join u in _db.Users.AsNoTracking() on l.UserId equals u.Id into uj + from u in uj.DefaultIfEmpty() + select new AuditRow( + l.Id, l.CreatedAt, + l.UserId, u != null ? u.FullName : null, + l.Action, l.EntityType, l.EntityId, + l.ChangesJson)) + .ToListAsync(ct); + + return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; + } +} diff --git a/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs b/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs index ad4c13e..d635bcb 100644 --- a/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs +++ b/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs @@ -69,6 +69,16 @@ public bool IsSuperAdmin } } + public Guid? UserId + { + get + { + var sub = _accessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? _accessor.HttpContext?.User?.FindFirst("sub")?.Value; + return Guid.TryParse(sub, out var id) ? id : null; + } + } + public bool IsTenantOverride { get diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index d46a5d6..56668f3 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -46,6 +46,9 @@ // включаются ниже через UseHttpMetrics(). /metrics endpoint доступен всем // (стандартная практика для Prometheus scrape) — на prod закрываем nginx'ом. builder.Services.AddSingleton(); + // OrgAuditInterceptor — scoped (зависит от ITenantContext). EF тащит его + // через AddInterceptors на каждое создание DbContext (DbContext тоже scoped). + builder.Services.AddScoped(); builder.Services.AddDbContext((sp, opts) => { opts.UseNpgsql(builder.Configuration.GetConnectionString("Default"), @@ -53,6 +56,8 @@ opts.UseOpenIddict(); opts.AddInterceptors(sp.GetRequiredService< foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>()); + opts.AddInterceptors(sp.GetRequiredService< + foodmarket.Infrastructure.Persistence.OrgAuditInterceptor>()); }); builder.Services.AddIdentity(opts => diff --git a/src/food-market.application/Common/Tenancy/ITenantContext.cs b/src/food-market.application/Common/Tenancy/ITenantContext.cs index 139cc72..00dcd49 100644 --- a/src/food-market.application/Common/Tenancy/ITenantContext.cs +++ b/src/food-market.application/Common/Tenancy/ITenantContext.cs @@ -9,4 +9,9 @@ public interface ITenantContext /// В этом режиме query-filter ОБЯЗАН применяться (по выбранной orgId), /// иначе SuperAdmin'у возвращаются записи всех орг — нарушение изоляции. bool IsTenantOverride { get; } + + /// Id текущего пользователя (sub claim в JWT). null для системных + /// операций без аутентификации (seed, фоновые джобы). Используется + /// для аудит-лога — кто инициировал мутацию. + Guid? UserId { get; } } diff --git a/src/food-market.domain/Organizations/OrgAuditLog.cs b/src/food-market.domain/Organizations/OrgAuditLog.cs new file mode 100644 index 0000000..60b5d36 --- /dev/null +++ b/src/food-market.domain/Organizations/OrgAuditLog.cs @@ -0,0 +1,24 @@ +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Organizations; + +/// Журнал действий внутри организации: создание/правка/удаление +/// доменных сущностей (Supply, RetailSale, Demand, Product, Counterparty). +/// Tenant-scoped — каждая орга видит ТОЛЬКО свой журнал; SuperAdmin видит +/// всё (как и для прочих TenantEntity). +/// +/// Пишется автоматически через OrgAuditInterceptor на SaveChanges: +/// для отслеживаемых типов снимается diff (old/new значения свойств), +/// сериализуется в JSON и сохраняется одной строкой. +public class OrgAuditLog : TenantEntity +{ + public Guid? UserId { get; set; } + /// "create" | "update" | "delete". + public string Action { get; set; } = ""; + /// Имя CLR-типа без неймспейса: "RetailSale", "Product"… + public string EntityType { get; set; } = ""; + public Guid? EntityId { get; set; } + /// JSON-объект формата { "field": { "before": ..., "after": ... }, ... }. + /// Для create — поля только в "after"; для delete — только "before". + public string ChangesJson { get; set; } = "{}"; +} diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index 0bf831b..084d951 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -70,6 +70,12 @@ public AppDbContext(DbContextOptions options, ITenantContext tenan public DbSet Employees => Set(); public DbSet EmployeeRetailPointAssignments => Set(); public DbSet SuperAdminAuditLogs => Set(); + public DbSet OrgAuditLogs => Set(); + + /// Если true — не пишет audit-строки + /// для этого SaveChanges. Используется сидерами/миграциями, фоновыми + /// импортами (где tenant-context отсутствует) и при ручной чистке логов. + public bool SkipAudit { get; set; } public DbSet SystemSettings => Set(); public DbSet PlatformSettings => Set(); @@ -130,6 +136,17 @@ protected override void OnModelCreating(ModelBuilder builder) b.HasIndex(x => new { x.OrganizationId, x.CreatedAt }); }); + builder.Entity(b => + { + b.ToTable("org_audit_log"); + b.Property(x => x.Action).HasMaxLength(20).IsRequired(); + b.Property(x => x.EntityType).HasMaxLength(100).IsRequired(); + b.Property(x => x.ChangesJson).HasColumnType("jsonb").IsRequired(); + b.HasIndex(x => new { x.OrganizationId, x.CreatedAt }); + b.HasIndex(x => new { x.OrganizationId, x.EntityType, x.EntityId }); + b.HasIndex(x => new { x.OrganizationId, x.UserId, x.CreatedAt }); + }); + builder.ConfigureCatalog(); builder.ConfigureInventory(); builder.ConfigurePurchases(); diff --git a/src/food-market.infrastructure/Persistence/DesignTimeAppDbContextFactory.cs b/src/food-market.infrastructure/Persistence/DesignTimeAppDbContextFactory.cs index 66fb980..1670b29 100644 --- a/src/food-market.infrastructure/Persistence/DesignTimeAppDbContextFactory.cs +++ b/src/food-market.infrastructure/Persistence/DesignTimeAppDbContextFactory.cs @@ -21,5 +21,6 @@ private sealed class DesignTimeTenantContext : ITenantContext public bool IsAuthenticated => false; public bool IsSuperAdmin => true; public bool IsTenantOverride => false; + public Guid? UserId => null; } } diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260528210000_Phase8b_OrgAuditLog.cs b/src/food-market.infrastructure/Persistence/Migrations/20260528210000_Phase8b_OrgAuditLog.cs new file mode 100644 index 0000000..099b04d --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260528210000_Phase8b_OrgAuditLog.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase8b — org_audit_log: per-tenant журнал мутаций. + /// + /// Пишется через OrgAuditInterceptor (SaveChangesInterceptor) на + /// отслеживаемые типы. Tenant-scoped — query-filter ограничивает выборку + /// своей оргой; SuperAdmin без override видит всё. ChangesJson — jsonb + /// формата { "field": { "before": ..., "after": ... } }. + [DbContext(typeof(AppDbContext))] + [Migration("20260528210000_Phase8b_OrgAuditLog")] + public partial class Phase8b_OrgAuditLog : Migration + { + protected override void Up(MigrationBuilder b) + { + b.Sql(@" + CREATE TABLE IF NOT EXISTS public.org_audit_log ( + ""Id"" uuid PRIMARY KEY, + ""OrganizationId"" uuid NOT NULL, + ""UserId"" uuid, + ""Action"" varchar(20) NOT NULL, + ""EntityType"" varchar(100) NOT NULL, + ""EntityId"" uuid, + ""ChangesJson"" jsonb NOT NULL, + ""CreatedAt"" timestamp with time zone NOT NULL, + ""UpdatedAt"" timestamp with time zone + ); + + CREATE INDEX IF NOT EXISTS ""IX_org_audit_log_OrganizationId_CreatedAt"" + ON public.org_audit_log (""OrganizationId"", ""CreatedAt""); + CREATE INDEX IF NOT EXISTS ""IX_org_audit_log_OrganizationId_EntityType_EntityId"" + ON public.org_audit_log (""OrganizationId"", ""EntityType"", ""EntityId""); + CREATE INDEX IF NOT EXISTS ""IX_org_audit_log_OrganizationId_UserId_CreatedAt"" + ON public.org_audit_log (""OrganizationId"", ""UserId"", ""CreatedAt""); + "); + } + + protected override void Down(MigrationBuilder b) + { + b.Sql(@"DROP TABLE IF EXISTS public.org_audit_log;"); + } + } +} diff --git a/src/food-market.infrastructure/Persistence/OrgAuditInterceptor.cs b/src/food-market.infrastructure/Persistence/OrgAuditInterceptor.cs new file mode 100644 index 0000000..e76704e --- /dev/null +++ b/src/food-market.infrastructure/Persistence/OrgAuditInterceptor.cs @@ -0,0 +1,132 @@ +using System.Text.Json; +using foodmarket.Application.Common.Tenancy; +using foodmarket.Domain.Catalog; +using foodmarket.Domain.Organizations; +using foodmarket.Domain.Purchases; +using foodmarket.Domain.Sales; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace foodmarket.Infrastructure.Persistence; + +/// EF Core SaveChangesInterceptor, который снимает diff +/// отслеживаемых сущностей и пишет строки . +/// +/// Срабатывает в SavingChanges (до commit): мы не плодим лог для +/// rolled-back изменений. Лог записывается в тот же DbContext в коллекции +/// будущей SaveChanges'ов — НЕ как отдельный round-trip; одна транзакция, +/// одна атомарность. Параметр SkipAudit позволяет отключить +/// инструментацию для специфических операций (seed, миграция). +/// +/// Отслеживаемые типы — белый список (см. IsTracked); расширять +/// сюда, не через рефлексию по всем DbSet'ам, чтобы аудит-лог не +/// раздувался самой меняющей его строкой. +public sealed class OrgAuditInterceptor : SaveChangesInterceptor +{ + private readonly ITenantContext _tenant; + + public OrgAuditInterceptor(ITenantContext tenant) => _tenant = tenant; + + public override InterceptionResult SavingChanges( + DbContextEventData eventData, InterceptionResult result) + { + WriteAuditEntries(eventData.Context); + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, CancellationToken ct = default) + { + WriteAuditEntries(eventData.Context); + return base.SavingChangesAsync(eventData, result, ct); + } + + private void WriteAuditEntries(DbContext? ctx) + { + if (ctx is not AppDbContext db) return; + if (db.SkipAudit) return; + // Аудитные записи не аудируем (бесконечная рекурсия). + var entries = db.ChangeTracker.Entries() + .Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) + .Where(e => IsTracked(e.Entity)) + .ToList(); + if (entries.Count == 0) return; + + var orgId = _tenant.OrganizationId; + if (orgId is null) return; // системные операции (SuperAdmin без override / seed) + + var userId = _tenant.UserId; + foreach (var entry in entries) + { + var (action, diff) = ComputeDiff(entry); + // Diff может быть пустым (Update без реальных изменений) — пропускаем. + if (action == "update" && diff.Count == 0) continue; + var id = entry.Properties.FirstOrDefault(p => p.Metadata.IsPrimaryKey())?.CurrentValue as Guid?; + db.Add(new OrgAuditLog + { + OrganizationId = orgId.Value, + UserId = userId, + Action = action, + EntityType = entry.Entity.GetType().Name, + EntityId = id, + ChangesJson = JsonSerializer.Serialize(diff), + }); + } + } + + private static bool IsTracked(object entity) => entity switch + { + Supply or SupplyLine => true, + SupplierReturn or SupplierReturnLine => true, + RetailSale or RetailSaleLine => true, + Demand or DemandLine => true, + Product or ProductPrice or ProductBarcode => true, + Counterparty => true, + _ => false, + }; + + private static (string Action, Dictionary Diff) ComputeDiff(EntityEntry entry) + { + var diff = new Dictionary(); + switch (entry.State) + { + case EntityState.Added: + foreach (var p in entry.Properties) + { + if (IsSkippable(p.Metadata.Name)) continue; + if (p.CurrentValue is null) continue; + diff[p.Metadata.Name] = new { after = p.CurrentValue }; + } + return ("create", diff); + case EntityState.Modified: + foreach (var p in entry.Properties) + { + if (IsSkippable(p.Metadata.Name)) continue; + if (!p.IsModified) continue; + if (Equals(p.OriginalValue, p.CurrentValue)) continue; + diff[p.Metadata.Name] = new { before = p.OriginalValue, after = p.CurrentValue }; + } + return ("update", diff); + case EntityState.Deleted: + foreach (var p in entry.Properties) + { + if (IsSkippable(p.Metadata.Name)) continue; + if (p.OriginalValue is null) continue; + diff[p.Metadata.Name] = new { before = p.OriginalValue }; + } + return ("delete", diff); + default: + return ("noop", diff); + } + } + + /// Поля, которые не имеет смысла включать в diff — служебные + /// и шумные. Tenant, timestamps, навигационные FK дублируются с самими + /// сущностями, а CreatedAt/UpdatedAt меняются при каждом сохранении. + private static bool IsSkippable(string name) => name switch + { + "OrganizationId" or "CreatedAt" or "UpdatedAt" => true, + _ => false, + }; +} diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 0ca7b52..baaeb64 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -40,6 +40,7 @@ import { SupplierReturnsPage } from '@/pages/SupplierReturnsPage' import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage' import { DemandsPage } from '@/pages/DemandsPage' import { DemandEditPage } from '@/pages/DemandEditPage' +import { OrgAuditLogPage } from '@/pages/OrgAuditLogPage' import { SalesReportPage } from '@/pages/SalesReportPage' import { StockReportPage } from '@/pages/StockReportPage' import { ProfitReportPage } from '@/pages/ProfitReportPage' @@ -141,6 +142,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index 0fcf980..a7958a5 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -7,7 +7,7 @@ import { cn } from '@/lib/utils' import { LayoutDashboard, Package, FolderTree, Ruler, Tag, Users, Warehouse, Store as StoreIcon, LogOut, Download, UserCog, Shield, ShieldCheck, - Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, TrendingUp, Target, Send, + Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, TrendingUp, Target, Send, FileText, } from 'lucide-react' import { Logo } from './Logo' import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner' @@ -129,6 +129,7 @@ function buildNav(roles: string[]): NavSection[] { { to: '/catalog/retail-points', icon: StoreIcon, label: 'Кассы' }, { to: '/settings/employees', icon: UserCog, label: 'Сотрудники' }, { to: '/settings/employee-roles', icon: Shield, label: 'Роли' }, + { to: '/audit-log', icon: FileText, label: 'Журнал изменений' }, ]}) } diff --git a/src/food-market.web/src/pages/OrgAuditLogPage.tsx b/src/food-market.web/src/pages/OrgAuditLogPage.tsx new file mode 100644 index 0000000..c08736e --- /dev/null +++ b/src/food-market.web/src/pages/OrgAuditLogPage.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { api } from '@/lib/api' +import { ListPageShell } from '@/components/ListPageShell' +import { DataTable } from '@/components/DataTable' +import { Pagination } from '@/components/Pagination' +import { SearchBar } from '@/components/SearchBar' +import { Field, Select } from '@/components/Field' +import type { PagedResult } from '@/lib/types' + +interface AuditRow { + id: string + createdAt: string + userId: string | null + userName: string | null + action: string + entityType: string + entityId: string | null + changesJson: string +} + +const ACTIONS = [ + { value: '', label: 'Все' }, + { value: 'create', label: 'Создание' }, + { value: 'update', label: 'Изменение' }, + { value: 'delete', label: 'Удаление' }, +] + +const ENTITY_TYPES = [ + { value: '', label: 'Все' }, + { value: 'Supply', label: 'Приёмки' }, + { value: 'SupplierReturn', label: 'Возвраты поставщикам' }, + { value: 'RetailSale', label: 'Чеки розничные' }, + { value: 'Demand', label: 'Оптовые отгрузки' }, + { value: 'Product', label: 'Товары' }, + { value: 'Counterparty', label: 'Контрагенты' }, +] + +/** Журнал мутаций tenant'а — кто, что и когда менял. Read-only. + * Запись делает OrgAuditInterceptor автоматически на каждом SaveChanges. */ +export function OrgAuditLogPage() { + const [page, setPage] = useState(1) + const [entityType, setEntityType] = useState('') + const [action, setAction] = useState('') + const [search, setSearch] = useState('') + + const params = new URLSearchParams({ page: String(page), pageSize: '50' }) + if (entityType) params.set('entityType', entityType) + if (action) params.set('action', action) + + const rep = useQuery({ + queryKey: ['audit-log', page, entityType, action], + queryFn: async () => (await api.get>(`/api/admin/audit-log?${params}`)).data, + placeholderData: (prev) => prev, + }) + + const filtered = (rep.data?.items ?? []).filter((r) => { + if (!search) return true + const s = search.toLowerCase() + return (r.userName ?? '').toLowerCase().includes(s) + || r.entityType.toLowerCase().includes(s) + || r.changesJson.toLowerCase().includes(s) + }) + + return ( + + + > + } + footer={rep.data && rep.data.total > 0 && ( + + )} + > + + + { setEntityType(e.target.value); setPage(1) }}> + {ENTITY_TYPES.map((t) => {t.label})} + + + + { setAction(e.target.value); setPage(1) }}> + {ACTIONS.map((a) => {a.label})} + + + + + r.id} + columns={[ + { header: 'Когда', width: '170px', cell: (r) => new Date(r.createdAt).toLocaleString('ru') }, + { header: 'Кто', width: '180px', cell: (r) => r.userName ?? система }, + { header: 'Тип', width: '140px', cell: (r) => r.entityType }, + { header: 'Действие', width: '110px', cell: (r) => ( + {r.action} + )}, + { header: 'EntityId', width: '110px', cell: (r) => ( + r.entityId ? {r.entityId.slice(0, 8)} : '—' + )}, + { header: 'Изменения', cell: (r) => ( + + показать diff + { + (() => { + try { return JSON.stringify(JSON.parse(r.changesJson), null, 2) } + catch { return r.changesJson } + })() + } + + )}, + ]} + empty="Событий пока нет." + /> + + ) +} diff --git a/tests/food-market.IntegrationTests/OrgAuditLogTests.cs b/tests/food-market.IntegrationTests/OrgAuditLogTests.cs new file mode 100644 index 0000000..6efcbd9 --- /dev/null +++ b/tests/food-market.IntegrationTests/OrgAuditLogTests.cs @@ -0,0 +1,83 @@ +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(); + } +} diff --git a/tests/food-market.UnitTests/Support/FakeTenantContext.cs b/tests/food-market.UnitTests/Support/FakeTenantContext.cs index e08f666..ad9dcc3 100644 --- a/tests/food-market.UnitTests/Support/FakeTenantContext.cs +++ b/tests/food-market.UnitTests/Support/FakeTenantContext.cs @@ -10,4 +10,5 @@ public sealed class FakeTenantContext : ITenantContext public bool IsAuthenticated { get; set; } = true; public bool IsSuperAdmin { get; set; } public bool IsTenantOverride { get; set; } + public Guid? UserId { get; set; } }
{ + (() => { + try { return JSON.stringify(JSON.parse(r.changesJson), null, 2) } + catch { return r.changesJson } + })() + }