From 305646b46797d71b012d48411ed0a880fd4caf03 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 11:56:37 -0400 Subject: [PATCH 1/4] Add the audit log screen with export download and item history --- src/FluentCMS.Admin/AdminRoutes.cs | 5 +- src/FluentCMS.Admin/Audit/AuditExportForm.cs | 146 +++++++++++ .../Components/Layout/MainLayout.razor | 1 + .../Components/Pages/AuditLog.razor | 232 +++++++++++++++++ src/FluentCMS.Admin/Components/Ui/Badge.razor | 24 ++ src/FluentCMS.Core/Audit/AuditErrorCodes.cs | 9 + src/FluentCMS.Core/Audit/AuditExporter.cs | 17 +- src/FluentCMS.Core/Audit/AuditQuery.cs | 6 +- src/FluentCMS.Core/Audit/IAuditExporter.cs | 3 +- .../Audit/AuditExportEndpoint.cs | 64 +++++ src/FluentCMS.Host/FluentCmsHost.cs | 2 + .../Audit/AuditExportTests.cs | 36 ++- .../Audit/AuditScenario.cs | 6 +- .../Audit/AuditScreenTests.cs | 234 ++++++++++++++++++ .../Admin/AuditExportFormTests.cs | 97 ++++++++ tests/FluentCMS.Tests/Admin/BadgeTests.cs | 46 ++++ .../Audit/AuditExporterTests.cs | 87 ++++++- 17 files changed, 1000 insertions(+), 15 deletions(-) create mode 100644 src/FluentCMS.Admin/Audit/AuditExportForm.cs create mode 100644 src/FluentCMS.Admin/Components/Pages/AuditLog.razor create mode 100644 src/FluentCMS.Admin/Components/Ui/Badge.razor create mode 100644 src/FluentCMS.Core/Audit/AuditErrorCodes.cs create mode 100644 src/FluentCMS.Host/Audit/AuditExportEndpoint.cs create mode 100644 tests/FluentCMS.IntegrationTests/Audit/AuditScreenTests.cs create mode 100644 tests/FluentCMS.Tests/Admin/AuditExportFormTests.cs create mode 100644 tests/FluentCMS.Tests/Admin/BadgeTests.cs diff --git a/src/FluentCMS.Admin/AdminRoutes.cs b/src/FluentCMS.Admin/AdminRoutes.cs index a6ffe0657..efca34ef8 100644 --- a/src/FluentCMS.Admin/AdminRoutes.cs +++ b/src/FluentCMS.Admin/AdminRoutes.cs @@ -1,11 +1,14 @@ namespace FluentCMS.Admin; -// The account routes the host needs for the cookie's redirects; each matches the @page directive of its page. +// The routes the host needs: the account pages for the cookie's redirects, and the audit page with the download it posts to. +// Each page route matches the @page directive of its page. public static class AdminRoutes { public const string Setup = "/setup"; public const string SignIn = "/sign-in"; public const string SignOut = "/sign-out"; + public const string Audit = "/audit"; + public const string AuditExport = "/audit/export"; // Only a path on this site is followed after sign-in, so a crafted link cannot send someone elsewhere. public static bool IsLocal(string? url) diff --git a/src/FluentCMS.Admin/Audit/AuditExportForm.cs b/src/FluentCMS.Admin/Audit/AuditExportForm.cs new file mode 100644 index 000000000..0a4ccd208 --- /dev/null +++ b/src/FluentCMS.Admin/Audit/AuditExportForm.cs @@ -0,0 +1,146 @@ +using System.Globalization; +using FluentCMS.Core.Audit; +using FluentCMS.Core.Results; + +namespace FluentCMS.Admin.Audit; + +public sealed record AuditExportRequest(AuditQuery Query, AuditExportFormat Format); + +// The export form as the audit page posts it to the host's download endpoint, which sends a rejected one back with ReturnQuery. +public sealed class AuditExportForm +{ + public const string FromInvalid = "audit_from_invalid"; + public const string ToInvalid = "audit_to_invalid"; + public const string SubjectIdInvalid = "audit_subject_id_invalid"; + + // The query names the page reads a rejected form back from. + public const string FromName = "from"; + public const string ToName = "to"; + public const string SubjectTypeName = "subjectType"; + public const string SubjectIdName = "subjectId"; + public const string ErrorName = "error"; + + // UTC dates as a date input sends them, both included. + public string? From { get; set; } + + public string? To { get; set; } + + public string? SubjectType { get; set; } + + public string? SubjectId { get; set; } + + public string? Format { get; set; } + + public Result ToRequest() + { + var errors = new List(); + + var from = ReadDate(From, FromInvalid, errors); + var to = ReadDate(To, ToInvalid, errors); + if (from > to) + { + errors.Add(new Error(AuditErrorCodes.WindowInverted, "The end date is before the start date.")); + } + + var subject = ReadSubject(errors); + var format = ReadFormat(errors); + + if (errors.Count > 0) + { + return Result.Failure(errors); + } + + var query = new AuditQuery + { + From = from is { } start ? Midnight(start) : null, + To = to is { } end && end < DateOnly.MaxValue ? Midnight(end.AddDays(1)) : null, + Subject = subject, + }; + + var valid = query.Validate(); + return valid.IsSuccess + ? Result.Success(new AuditExportRequest(query, format!.Value)) + : Result.Failure(valid.Errors); + } + + public IEnumerable> ReturnQuery(IEnumerable errors) + { + (string Name, string? Value)[] fields = + [ + (FromName, From), + (ToName, To), + (SubjectTypeName, SubjectType), + (SubjectIdName, SubjectId), + ]; + + return fields + .Where(field => !string.IsNullOrWhiteSpace(field.Value)) + .Select(field => KeyValuePair.Create(field.Name, field.Value)) + .Concat(errors.Select(error => error.Code).Distinct().Select(code => KeyValuePair.Create(ErrorName, (string?)code))); + } + + private static DateOnly? ReadDate(string? text, string code, List errors) + { + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + if (DateOnly.TryParseExact(text.Trim(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + { + return date; + } + + errors.Add(new Error(code, $"'{text}' is not a date in the form YYYY-MM-DD.")); + return null; + } + + private AuditSubject? ReadSubject(List errors) + { + var type = SubjectType?.Trim(); + var id = SubjectId?.Trim(); + + if (string.IsNullOrEmpty(type) && string.IsNullOrEmpty(id)) + { + return null; + } + + if (string.IsNullOrEmpty(type)) + { + errors.Add(new Error(AuditErrorCodes.SubjectTypeMissing, "An item's history needs the item's type.")); + } + + if (string.IsNullOrEmpty(id)) + { + errors.Add(new Error(AuditErrorCodes.SubjectIdMissing, "An item's history needs the item's id.")); + return null; + } + + if (!Guid.TryParse(id, out var subjectId)) + { + errors.Add(new Error(SubjectIdInvalid, $"'{id}' is not an item id.")); + return null; + } + + return new AuditSubject(type ?? string.Empty, subjectId); + } + + private AuditExportFormat? ReadFormat(List errors) + { + switch (Format?.Trim().ToLowerInvariant()) + { + case "json": + return AuditExportFormat.Json; + case "csv": + return AuditExportFormat.Csv; + default: + errors.Add(new Error(AuditErrorCodes.ExportFormatUnknown, "Choose JSON or CSV.")); + return null; + } + } + + private static DateTimeOffset Midnight(DateOnly date) + { + return new DateTimeOffset(date.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); + } +} diff --git a/src/FluentCMS.Admin/Components/Layout/MainLayout.razor b/src/FluentCMS.Admin/Components/Layout/MainLayout.razor index 695b071b1..284087184 100644 --- a/src/FluentCMS.Admin/Components/Layout/MainLayout.razor +++ b/src/FluentCMS.Admin/Components/Layout/MainLayout.razor @@ -8,6 +8,7 @@ FluentCMS @if (Initiator.Current is UserInitiator user) { + @L["Audit log"] @* A plain form, so it posts to the sign-out page's handler from any page, static or interactive. *@
diff --git a/src/FluentCMS.Admin/Components/Pages/AuditLog.razor b/src/FluentCMS.Admin/Components/Pages/AuditLog.razor new file mode 100644 index 000000000..b4f75fb17 --- /dev/null +++ b/src/FluentCMS.Admin/Components/Pages/AuditLog.razor @@ -0,0 +1,232 @@ +@page "/audit" +@using System.Globalization +@using FluentCMS.Admin.Audit +@using FluentCMS.Core.Audit +@inject IAuditReader Reader +@inject IStringLocalizer L + +@L["Audit log"] + +

@L["Audit log"]

+

@L["Every operation is recorded: who or what acted, with which model and sources, in which change set."]

+ +
+ + +

@L["Export"]

+ @L["Download the whole log, a date range or one item's history as JSON or CSV. Dates are in UTC and include both days. Every export is recorded in the log."] +
+ + @* Posts to the host's download endpoint, which answers with the file or sends a rejected form back here. *@ + + + @if (FormError is { } formError) + { + + } +
+ + + + + + + + + + + + +
+
+ + +
+ +
+
+ + + +

@L["Item history"]

+ @L["Every event recorded for one item, oldest first."] +
+ +
+ + + + + + + +
+ + @if (_history is { Count: 0 }) + { +

@L["Nothing is recorded for this item."]

+ } + else if (_history is not null) + { +
    + @foreach (var auditEvent in _history) + { +
  1. +
    + @auditEvent.Operation + @if (auditEvent.Initiator is AgentInitiator) + { + @L["Agent"] + } + else + { + @L["User"] + } + @auditEvent.Initiator.DisplayName + +
    +
    + @Detail(L["Agent credential"], auditEvent.AgentCredentialId?.ToString()) + @Detail(L["Model"], auditEvent.Model) + @Detail(L["Prompt hash"], auditEvent.PromptHash) + @Detail(L["Change set"], auditEvent.ChangeSetId?.ToString()) + @Detail(L["Previous version"], auditEvent.PreviousVersionId?.ToString()) + @if (auditEvent.Sources.Count > 0) + { +
    @L["Sources"]
    +
    +
      + @foreach (var source in auditEvent.Sources) + { +
    • @source
    • + } +
    +
    + } + @Detail(L["Event id"], auditEvent.Id.ToString()) +
    +
  2. + } +
+ @if (_historyTruncated) + { +

@L["Only the first {0} events are shown. Export the item's history for all of them.", HistoryLimit]

+ } + } +
+
+
+ +@code { + private const int HistoryLimit = 200; + + private List? _history; + private bool _historyTruncated; + private string? _historyTypeError; + private string? _historyIdError; + + [SupplyParameterFromQuery(Name = AuditExportForm.FromName)] + private string? From { get; set; } + + [SupplyParameterFromQuery(Name = AuditExportForm.ToName)] + private string? To { get; set; } + + [SupplyParameterFromQuery(Name = AuditExportForm.SubjectTypeName)] + private string? SubjectType { get; set; } + + [SupplyParameterFromQuery(Name = AuditExportForm.SubjectIdName)] + private string? SubjectId { get; set; } + + [SupplyParameterFromQuery(Name = AuditExportForm.ErrorName)] + private string[]? ExportErrors { get; set; } + + [SupplyParameterFromQuery(Name = "type")] + private string? HistoryType { get; set; } + + [SupplyParameterFromQuery(Name = "id")] + private string? HistoryId { get; set; } + + // Codes the form shows next to a field; any other code the endpoint sends back is shown above the form. + private static readonly HashSet FieldCodes = + [ + AuditExportForm.FromInvalid, + AuditExportForm.ToInvalid, + AuditErrorCodes.WindowInverted, + AuditErrorCodes.SubjectTypeMissing, + AuditErrorCodes.SubjectIdMissing, + AuditExportForm.SubjectIdInvalid, + ]; + + private string? FormError => (ExportErrors ?? []).FirstOrDefault(code => !FieldCodes.Contains(code)) switch + { + null => null, + AuditErrorCodes.ExportFormatUnknown => L["Choose JSON or CSV."].Value, + _ => L["The export could not be made. Check the fields and try again."].Value, + }; + + protected override async Task OnInitializedAsync() + { + if (string.IsNullOrWhiteSpace(HistoryType) && string.IsNullOrWhiteSpace(HistoryId)) + { + return; + } + + if (string.IsNullOrWhiteSpace(HistoryType)) + { + _historyTypeError = L["Enter the item's type."]; + } + + if (!Guid.TryParse(HistoryId?.Trim(), out var id) || id == Guid.Empty) + { + _historyIdError = L["Enter the item's id, such as 3f2504e0-4f89-41d3-9a0c-0305e82c3301."]; + } + + if (_historyTypeError is not null || _historyIdError is not null) + { + return; + } + + var read = Reader.Read(new AuditQuery { Subject = new AuditSubject(HistoryType!.Trim(), id) }); + if (!read.IsSuccess) + { + return; + } + + _history = []; + await foreach (var auditEvent in read.Value) + { + if (_history.Count == HistoryLimit) + { + _historyTruncated = true; + break; + } + + _history.Add(auditEvent); + } + } + + private string? ExportError(params string[] codes) + { + var code = codes.FirstOrDefault(code => ExportErrors?.Contains(code) == true); + return code switch + { + null => null, + AuditExportForm.FromInvalid or AuditExportForm.ToInvalid => L["Enter a date as YYYY-MM-DD."].Value, + AuditErrorCodes.WindowInverted => L["The end date is before the start date."].Value, + AuditErrorCodes.SubjectTypeMissing => L["Enter the item's type, or leave both item fields empty."].Value, + AuditErrorCodes.SubjectIdMissing => L["Enter the item's id, or leave both item fields empty."].Value, + _ => L["Enter the item's id, such as 3f2504e0-4f89-41d3-9a0c-0305e82c3301."].Value, + }; + } + + private static RenderFragment? Detail(string label, string? value) => value is null + ? null + : @ +
@label
+
@value
+
; +} diff --git a/src/FluentCMS.Admin/Components/Ui/Badge.razor b/src/FluentCMS.Admin/Components/Ui/Badge.razor new file mode 100644 index 000000000..bf810c2e4 --- /dev/null +++ b/src/FluentCMS.Admin/Components/Ui/Badge.razor @@ -0,0 +1,24 @@ + + @ChildContent + + +@code { + // The shadcn badge, so a documentation example translates with the same variant names. + private const string Base = "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-md border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3"; + + [Parameter] public string Variant { get; set; } = "default"; + [Parameter] public string? Class { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } + + private static string VariantClasses(string variant) => variant switch + { + "secondary" => "border-transparent bg-secondary text-secondary-foreground", + "destructive" => "border-transparent bg-destructive text-white dark:bg-destructive/60", + "outline" => "text-foreground", + _ => "border-transparent bg-primary text-primary-foreground", + }; +} diff --git a/src/FluentCMS.Core/Audit/AuditErrorCodes.cs b/src/FluentCMS.Core/Audit/AuditErrorCodes.cs new file mode 100644 index 000000000..316824732 --- /dev/null +++ b/src/FluentCMS.Core/Audit/AuditErrorCodes.cs @@ -0,0 +1,9 @@ +namespace FluentCMS.Core.Audit; + +public static class AuditErrorCodes +{ + public const string WindowInverted = "audit_window_inverted"; + public const string SubjectTypeMissing = "audit_subject_type_missing"; + public const string SubjectIdMissing = "audit_subject_id_missing"; + public const string ExportFormatUnknown = "audit_export_format_unknown"; +} diff --git a/src/FluentCMS.Core/Audit/AuditExporter.cs b/src/FluentCMS.Core/Audit/AuditExporter.cs index a05fca5cb..3680660f7 100644 --- a/src/FluentCMS.Core/Audit/AuditExporter.cs +++ b/src/FluentCMS.Core/Audit/AuditExporter.cs @@ -1,11 +1,14 @@ using System.Text; using System.Text.Json; +using FluentCMS.Core.Persistence; using FluentCMS.Core.Results; namespace FluentCMS.Core.Audit; -internal sealed class AuditExporter(IAuditReader reader) : IAuditExporter +internal sealed class AuditExporter(IAuditReader reader, IUnitOfWork unitOfWork, IAuditLog auditLog) : IAuditExporter { + private const string Operation = "audit.exported"; + // Output reaches the destination in pieces of about this size, so a long range never builds up in memory. private const int ChunkSize = 16 * 1024; @@ -23,7 +26,7 @@ public async Task ExportAsync( if (!Enum.IsDefined(format)) { return Result.Failure( - "audit_export_format_unknown", + AuditErrorCodes.ExportFormatUnknown, $"The audit log exports as {string.Join(" or ", Enum.GetNames())}, not as {format}."); } @@ -33,6 +36,16 @@ public async Task ExportAsync( return Result.Failure(read.Errors); } + // Recorded before anything leaves, so an export that is cut off is on record too. The events are read only below, + // so a selection that reaches the present includes this one. + await using (var work = await unitOfWork.BeginAsync(cancellationToken)) + { + await auditLog.AppendAsync( + new NewAuditEvent(Operation) { SubjectType = query.Subject?.Type, SubjectId = query.Subject?.Id }, + cancellationToken); + await work.CommitAsync(cancellationToken); + } + if (format == AuditExportFormat.Json) { await WriteJsonAsync(read.Value, destination, cancellationToken); diff --git a/src/FluentCMS.Core/Audit/AuditQuery.cs b/src/FluentCMS.Core/Audit/AuditQuery.cs index 4e7198652..284159450 100644 --- a/src/FluentCMS.Core/Audit/AuditQuery.cs +++ b/src/FluentCMS.Core/Audit/AuditQuery.cs @@ -18,19 +18,19 @@ public Result Validate() if (From > To) { - errors.Add(new Error("audit_window_inverted", $"The window starts at {From:O}, after it ends at {To:O}.")); + errors.Add(new Error(AuditErrorCodes.WindowInverted, $"The window starts at {From:O}, after it ends at {To:O}.")); } if (Subject is { } subject) { if (string.IsNullOrWhiteSpace(subject.Type)) { - errors.Add(new Error("audit_subject_type_missing", "An item's history needs the item's type.")); + errors.Add(new Error(AuditErrorCodes.SubjectTypeMissing, "An item's history needs the item's type.")); } if (subject.Id == Guid.Empty) { - errors.Add(new Error("audit_subject_id_missing", "An item's history needs the item's id.")); + errors.Add(new Error(AuditErrorCodes.SubjectIdMissing, "An item's history needs the item's id.")); } } diff --git a/src/FluentCMS.Core/Audit/IAuditExporter.cs b/src/FluentCMS.Core/Audit/IAuditExporter.cs index cce65b216..12c395c76 100644 --- a/src/FluentCMS.Core/Audit/IAuditExporter.cs +++ b/src/FluentCMS.Core/Audit/IAuditExporter.cs @@ -11,6 +11,7 @@ public enum AuditExportFormat public interface IAuditExporter { // Writes what the query selects, in reading order, while it is being read; the destination is flushed and left open. - // Fails before writing anything when the query does not validate or the format is unknown. + // First commits an "audit.exported" event naming the current initiator and, for one item's history, the item (AUD-01). + // Fails before recording or writing anything when the query does not validate or the format is unknown. Task ExportAsync(AuditQuery query, AuditExportFormat format, Stream destination, CancellationToken cancellationToken = default); } diff --git a/src/FluentCMS.Host/Audit/AuditExportEndpoint.cs b/src/FluentCMS.Host/Audit/AuditExportEndpoint.cs new file mode 100644 index 000000000..2c13b0cfc --- /dev/null +++ b/src/FluentCMS.Host/Audit/AuditExportEndpoint.cs @@ -0,0 +1,64 @@ +using FluentCMS.Admin; +using FluentCMS.Admin.Audit; +using FluentCMS.Core.Audit; +using FluentCMS.Core.Results; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; + +namespace FluentCMS.Host.Audit; + +// The download behind the audit page's export form: a page cannot answer with a file. It maps the form and calls the exporter, +// nothing more. Form binding makes the anti-forgery check apply, as it does to every page form (SEC-07). +internal static class AuditExportEndpoint +{ + public static void MapAuditExport(this IEndpointRouteBuilder endpoints) + { + // A forged token gets a bare 400. Re-executing the not-found page would read the rejected form again and fail with a 500. + endpoints.MapPost(AdminRoutes.AuditExport, ExportAsync) + .RequireAuthorization() + .WithMetadata(new SkipStatusCodePagesAttribute()); + + // Someone whose session ran out signs in again and is sent back here with a GET; the form is on the page. + endpoints.MapGet(AdminRoutes.AuditExport, () => Results.LocalRedirect(AdminRoutes.Audit)).RequireAuthorization(); + } + + private static async Task ExportAsync(HttpContext context, [FromForm] AuditExportForm form, IAuditExporter exporter) + { + var request = form.ToRequest(); + if (!request.IsSuccess) + { + return BackToForm(form, request.Errors); + } + + var (query, format) = request.Value; + var response = context.Response; + response.ContentType = format == AuditExportFormat.Json ? "application/json; charset=utf-8" : "text/csv; charset=utf-8"; + response.Headers.CacheControl = "no-store"; + response.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment") + { + FileName = FileName(query, format), + }.ToString(); + + var exported = await exporter.ExportAsync(query, format, response.Body, context.RequestAborted); + if (!exported.IsSuccess) + { + // Nothing has been written, so the response can still turn into the redirect. + response.Clear(); + return BackToForm(form, exported.Errors); + } + + return Results.Empty; + } + + private static IResult BackToForm(AuditExportForm form, IEnumerable errors) + { + return Results.LocalRedirect(AdminRoutes.Audit + QueryString.Create(form.ReturnQuery(errors))); + } + + // No text the user typed goes into the header; an item id is a GUID. + private static string FileName(AuditQuery query, AuditExportFormat format) + { + var name = query.Subject is { } subject ? $"audit-history-{subject.Id:D}" : "audit-log"; + return name + (format == AuditExportFormat.Json ? ".json" : ".csv"); + } +} diff --git a/src/FluentCMS.Host/FluentCmsHost.cs b/src/FluentCMS.Host/FluentCmsHost.cs index 7abbd4b20..e2a81a281 100644 --- a/src/FluentCMS.Host/FluentCmsHost.cs +++ b/src/FluentCMS.Host/FluentCmsHost.cs @@ -3,6 +3,7 @@ using FluentCMS.Api; using FluentCMS.Core; using FluentCMS.Core.Identity; +using FluentCMS.Host.Audit; using FluentCMS.Host.Identity; using FluentCMS.Host.Jobs; using FluentCMS.Host.Security; @@ -57,6 +58,7 @@ public static WebApplication BuildWebApplication(string[] args, Action() diff --git a/tests/FluentCMS.IntegrationTests/Audit/AuditExportTests.cs b/tests/FluentCMS.IntegrationTests/Audit/AuditExportTests.cs index e8a428d52..8a4e9293f 100644 --- a/tests/FluentCMS.IntegrationTests/Audit/AuditExportTests.cs +++ b/tests/FluentCMS.IntegrationTests/Audit/AuditExportTests.cs @@ -71,7 +71,7 @@ public async Task A_single_items_history_can_be_reconstructed_from_a_json_or_csv [Theory] [InlineData(DatabaseProviders.Sqlite)] [PostgresData] - public async Task An_items_history_exports_on_its_own_and_an_empty_selection_exports_nothing(string provider) + public async Task An_items_history_exports_on_its_own_and_records_each_export_in_it(string provider) { await using var log = await AuditScenario.CreateAsync(provider); var price = new AuditSubject("entry", Guid.NewGuid()); @@ -83,14 +83,40 @@ await log.AppendAsync(Start, "entry.created", price), expected.Add(await log.AppendAsync(Start.AddHours(2), "entry.updated", price)); var item = new AuditQuery { Subject = price }; + var fromJson = ExportedEvents.FromJson(await log.ExportAsync(item, AuditExportFormat.Json, at: Start.AddHours(3))); + var fromCsv = ExportedEvents.FromCsv(await log.ExportAsync(item, AuditExportFormat.Csv, at: Start.AddHours(4))); - Assert.Equal(expected.Select(ExportedEvents.Fingerprint), ExportedEvents.FromJson(await log.ExportAsync(item, AuditExportFormat.Json)).Select(ExportedEvents.Fingerprint)); - Assert.Equal(expected.Select(ExportedEvents.Fingerprint), ExportedEvents.FromCsv(await log.ExportAsync(item, AuditExportFormat.Csv)).Select(ExportedEvents.Fingerprint)); + // An export is recorded before it reads, so each one is part of what it exports. + Assert.Equal(expected.Select(ExportedEvents.Fingerprint), fromJson.Take(2).Select(ExportedEvents.Fingerprint)); + AssertExportRecord(fromJson[2], price, Start.AddHours(3)); + Assert.Equal(fromJson.Select(ExportedEvents.Fingerprint), fromCsv.Take(3).Select(ExportedEvents.Fingerprint)); + AssertExportRecord(fromCsv[3], price, Start.AddHours(4)); + Assert.Equal(4, fromCsv.Count); + } + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_empty_selection_exports_nothing_but_the_export_is_still_recorded(string provider) + { + await using var log = await AuditScenario.CreateAsync(provider); + await log.AppendAsync(Start, "entry.created"); var nothing = new AuditQuery { To = Start }; - Assert.Equal("[]", await log.ExportAsync(nothing, AuditExportFormat.Json)); - Assert.Single(ExportedEvents.ParseCsv(await log.ExportAsync(nothing, AuditExportFormat.Csv))); + Assert.Equal("[]", await log.ExportAsync(nothing, AuditExportFormat.Json, at: End)); + Assert.Single(ExportedEvents.ParseCsv(await log.ExportAsync(nothing, AuditExportFormat.Csv, at: End))); + + var recorded = await log.ReadAsync(new AuditQuery { From = End }); + Assert.Equal(2, recorded.Count); + Assert.All(recorded, record => AssertExportRecord(record, null, End)); + } + + private static void AssertExportRecord(AuditEvent record, AuditSubject? subject, DateTimeOffset at) + { + Assert.Equal("audit.exported", record.Operation); + Assert.Equal(AuditScenario.Amir, record.Initiator); + Assert.Equal(at, record.Timestamp); + Assert.Equal((subject?.Type, subject?.Id), (record.SubjectType, record.SubjectId)); } private static IEnumerable OnlyThe(AuditSubject subject, IEnumerable events) diff --git a/tests/FluentCMS.IntegrationTests/Audit/AuditScenario.cs b/tests/FluentCMS.IntegrationTests/Audit/AuditScenario.cs index 9036aba91..7bc86ec84 100644 --- a/tests/FluentCMS.IntegrationTests/Audit/AuditScenario.cs +++ b/tests/FluentCMS.IntegrationTests/Audit/AuditScenario.cs @@ -60,8 +60,12 @@ public async Task> ReadAsync(AuditQuery query) return await read.Value.ToListAsync(); } - public async Task ExportAsync(AuditQuery query, AuditExportFormat format) + // An export records itself as Amir, at the time given or at the time of the last append. + public async Task ExportAsync(AuditQuery query, AuditExportFormat format, DateTimeOffset? at = null) { + _clock.Now = at ?? _clock.Now; + _initiator.Current = Amir; + await using var scope = _services.CreateAsyncScope(); using var destination = new MemoryStream(); var exported = await scope.ServiceProvider.GetRequiredService().ExportAsync(query, format, destination); diff --git a/tests/FluentCMS.IntegrationTests/Audit/AuditScreenTests.cs b/tests/FluentCMS.IntegrationTests/Audit/AuditScreenTests.cs new file mode 100644 index 000000000..658bf0234 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Audit/AuditScreenTests.cs @@ -0,0 +1,234 @@ +using System.Net; +using FluentCMS.Core.Audit; +using FluentCMS.Core.Identity; +using FluentCMS.Infrastructure.Audit; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests.Audit; + +public sealed class AuditScreenTests +{ + [Fact] + public async Task Anonymous_requests_for_the_page_or_the_download_go_to_sign_in_and_record_nothing() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + await host.CreateFirstAdministratorAsync(); + + var page = await host.Client.GetAsync("/audit"); + var download = await host.Client.PostAsync("/audit/export", new FormUrlEncodedContent(new Dictionary + { + ["Format"] = "json", + })); + + Assert.Equal(HttpStatusCode.Redirect, page.StatusCode); + Assert.Equal("/sign-in?ReturnUrl=%2Faudit", host.LocationOf(page)); + Assert.Equal(HttpStatusCode.Redirect, download.StatusCode); + Assert.StartsWith("/sign-in?", host.LocationOf(download)); + Assert.Empty(await RowsAsync(host, "audit.exported")); + } + + [Fact] + public async Task A_signed_in_download_returns_the_selection_as_json_or_csv_and_records_each_export() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + var ownerId = await host.CreateFirstAdministratorAsync(); + await host.SignInAsync(AdminHost.Owner); + + var json = await DownloadAsync(host, new Dictionary + { + ["SubjectType"] = "user", + ["SubjectId"] = ownerId.ToString(), + ["Format"] = "json", + }); + + Assert.Equal(HttpStatusCode.OK, json.StatusCode); + Assert.Equal("application/json", json.Content.Headers.ContentType?.MediaType); + Assert.Equal("attachment", json.Content.Headers.ContentDisposition?.DispositionType); + Assert.Equal($"audit-history-{ownerId}.json", FileName(json)); + Assert.True(json.Headers.CacheControl?.NoStore); + Assert.True(json.Headers.Contains("Content-Security-Policy")); + + var history = ExportedEvents.FromJson(await json.Content.ReadAsStringAsync()); + Assert.Equal(["user.signed_in", "audit.exported"], history.Select(auditEvent => auditEvent.Operation)); + Assert.All(history, auditEvent => Assert.Equal(("user", ownerId), (auditEvent.SubjectType, auditEvent.SubjectId))); + Assert.Equal(new UserInitiator(ownerId, AdminHost.Owner.DisplayName), history[1].Initiator); + + var today = DateTime.UtcNow.Date; + var csv = await DownloadAsync(host, new Dictionary + { + ["From"] = today.AddDays(-1).ToString("yyyy-MM-dd"), + ["To"] = today.AddDays(1).ToString("yyyy-MM-dd"), + ["Format"] = "csv", + }); + + Assert.Equal(HttpStatusCode.OK, csv.StatusCode); + Assert.Equal("text/csv", csv.Content.Headers.ContentType?.MediaType); + Assert.Equal("audit-log.csv", FileName(csv)); + + var log = ExportedEvents.FromCsv(await csv.Content.ReadAsStringAsync()); + Assert.Equal(["user.signed_in", "audit.exported", "audit.exported"], log.Select(auditEvent => auditEvent.Operation)); + Assert.Null(log[2].SubjectType); + + var recorded = await RowsAsync(host, "audit.exported"); + Assert.Equal(2, recorded.Count); + Assert.All(recorded, row => Assert.Equal((InitiatorKinds.User, ownerId), (row.InitiatorKind, row.InitiatorId))); + } + + [Fact] + public async Task A_rejected_export_returns_to_the_page_with_each_problem_next_to_its_field_and_records_nothing() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + await host.CreateFirstAdministratorAsync(); + await host.SignInAsync(AdminHost.Owner); + + var rejected = await DownloadAsync(host, new Dictionary + { + ["From"] = "2026-10-02", + ["To"] = "2026-10-01", + ["SubjectType"] = "entry", + ["SubjectId"] = "not-an-id", + ["Format"] = "json", + }); + + Assert.Equal(HttpStatusCode.Redirect, rejected.StatusCode); + var location = host.LocationOf(rejected)!; + Assert.StartsWith("/audit?", location); + + var page = await host.Client.GetStringAsync(location); + Assert.Contains("The end date is before the start date.", page); + Assert.Contains("such as 3f2504e0-4f89-41d3-9a0c-0305e82c3301", page); + Assert.Equal(2, Count(page, "aria-invalid=\"true\"")); + Assert.Contains("value=\"2026-10-02\"", page); + Assert.Contains("value=\"entry\"", page); + Assert.Contains("value=\"not-an-id\"", page); + Assert.Empty(await RowsAsync(host, "audit.exported")); + + // A session that ran out lands back on the download's address after sign-in; it leads to the page. + Assert.Equal("/audit", host.LocationOf(await host.Client.GetAsync("/audit/export"))); + } + + [Fact] + public async Task An_export_post_without_a_valid_antiforgery_token_is_rejected_and_records_nothing() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + await host.CreateFirstAdministratorAsync(); + await host.SignInAsync(AdminHost.Owner); + + var forged = await host.Client.PostAsync("/audit/export", new FormUrlEncodedContent(new Dictionary + { + ["Format"] = "json", + ["__RequestVerificationToken"] = "forged", + })); + + Assert.Equal(HttpStatusCode.BadRequest, forged.StatusCode); + Assert.Empty(await RowsAsync(host, "audit.exported")); + } + + [Fact] + public async Task The_history_lookup_lists_an_items_events_oldest_first_with_every_field() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + await host.CreateFirstAdministratorAsync(); + await host.SignInAsync(AdminHost.Owner); + + var entryId = Guid.NewGuid(); + var credentialId = Guid.NewGuid(); + var changeSetId = Guid.NewGuid(); + var previousVersionId = Guid.NewGuid(); + var start = new DateTimeOffset(2026, 10, 1, 9, 30, 0, TimeSpan.Zero); + var agentUpdate = Row(start, "entry.updated", entryId, InitiatorKinds.Agent, credentialId, "catalog-sync"); + agentUpdate.AgentCredentialId = credentialId; + agentUpdate.Model = "example-model"; + agentUpdate.PromptHash = "sha256:9f86d081"; + agentUpdate.Sources = ["https://example.com/prices", ""]; + agentUpdate.ChangeSetId = changeSetId; + agentUpdate.PreviousVersionId = previousVersionId; + await SeedAsync( + host, + Row(start.AddHours(2), "entry.approved", entryId, InitiatorKinds.User, Guid.NewGuid(), "Dana"), + agentUpdate, + Row(start.AddHours(1), "entry.updated", Guid.NewGuid(), InitiatorKinds.User, Guid.NewGuid(), "Someone else")); + + var home = await host.Client.GetStringAsync("/"); + Assert.Contains("href=\"audit\"", home); + + var page = await host.Client.GetStringAsync($"/audit?type=entry&id={entryId}"); + var events = page[page.IndexOf("data-testid=\"audit-history-events\"", StringComparison.Ordinal)..]; + + Assert.True(events.IndexOf("entry.updated", StringComparison.Ordinal) < events.IndexOf("entry.approved", StringComparison.Ordinal)); + Assert.DoesNotContain("Someone else", events); + Assert.Contains(">Agent", events); + Assert.Contains(">User", events); + Assert.Contains("catalog-sync", events); + Assert.Contains("2026-10-01 09:30:00", events); + Assert.Contains(credentialId.ToString(), events); + Assert.Contains("example-model", events); + Assert.Contains("sha256:9f86d081", events); + Assert.Contains("https://example.com/prices", events); + Assert.Contains("<script>alert(1)</script>", events); + Assert.DoesNotContain("