diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5d7061304..2f80bf6be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,7 +76,9 @@ Every operation that changes data runs inside a unit of work and appends the aud Nothing in the code updates or deletes an audit row. On PostgreSQL the `AuditEvents` migration also revokes `UPDATE`, `DELETE` and `TRUNCATE` from `PUBLIC` and from the role that applies it, which owns the table. A superuser bypasses privilege checks, so run the host, which applies migrations at startup, as an ordinary role that owns the database. SQLite has no roles; there the code is the guarantee. -`IAuditReader.Read(AuditQuery)` reads the log back: an optional window from `From` (inclusive) to `To` (exclusive) and an optional `AuditSubject` for one item's history, oldest first with ties broken by id, streamed from the database. With no window and no subject it is the full log. An inverted window or an incomplete subject is a failed `Result`. `IAuditExporter.ExportAsync` writes the same selection to a stream as JSON or CSV while it reads it. Both formats carry the same fields under the same names, in this order: `id`, `timestamp`, `operation`, `initiatorKind` (`user` or `agent`), `initiatorId`, `initiatorName`, `agentCredentialId`, `subjectType`, `subjectId`, `model`, `promptHash`, `sources`, `changeSetId` and `previousVersionId`. The JSON is an array of objects with every key present and `null` for an empty field. The CSV follows RFC 4180 with a header row and CRLF line ends, plus three conventions so every value reads back exactly: `sources` is one cell holding a JSON array, an unquoted empty cell is null while a quoted one is an empty string, and a value starting with `=`, `+`, `-`, `@`, a tab, a carriage return or `'` gets one leading `'`, so a spreadsheet does not run it as a formula; a reader drops that one `'`. +`IAuditReader.Read(AuditQuery)` reads the log back: an optional window from `From` (inclusive) to `To` (exclusive) and an optional `AuditSubject` for one item's history, oldest first with ties broken by id, streamed from the database. With no window and no subject it is the full log. An inverted window or an incomplete subject is a failed `Result`. `IAuditExporter.ExportAsync` writes the same selection to a stream as JSON or CSV while it reads it, after committing an `audit.exported` event that names who exported and, for one item's history, the item; viewing history is a read and records nothing. Both formats carry the same fields under the same names, in this order: `id`, `timestamp`, `operation`, `initiatorKind` (`user` or `agent`), `initiatorId`, `initiatorName`, `agentCredentialId`, `subjectType`, `subjectId`, `model`, `promptHash`, `sources`, `changeSetId` and `previousVersionId`. The JSON is an array of objects with every key present and `null` for an empty field. The CSV follows RFC 4180 with a header row and CRLF line ends, plus three conventions so every value reads back exactly: `sources` is one cell holding a JSON array, an unquoted empty cell is null while a quoted one is an empty string, and a value starting with `=`, `+`, `-`, `@`, a tab, a carriage return or `'` gets one leading `'`, so a spreadsheet does not run it as a formula; a reader drops that one `'`. + +The admin's `/audit` page offers the export and an item's history. A page cannot answer with a file, so its export form posts to `POST /audit/export` in the host, which maps the form, calls the exporter and streams the result; it needs a signed-in user and an anti-forgery token like every form, and sends a rejected form back to the page. ## Extension points diff --git a/src/FluentCMS.Admin/AdminRoutes.cs b/src/FluentCMS.Admin/AdminRoutes.cs index 7b10a64ae..11522e772 100644 --- a/src/FluentCMS.Admin/AdminRoutes.cs +++ b/src/FluentCMS.Admin/AdminRoutes.cs @@ -1,6 +1,7 @@ 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"; @@ -11,6 +12,9 @@ public static class AdminRoutes // Not a page: the host's endpoint that sends the browser to the external provider. public const string ExternalSignInStart = "/sign-in/external/start"; + 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..709e33398 --- /dev/null +++ b/src/FluentCMS.Host/Audit/AuditExportEndpoint.cs @@ -0,0 +1,61 @@ +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) + { + endpoints.MapPost(AdminRoutes.AuditExport, ExportAsync).RequireAuthorization(); + + // 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 94add0f27..6cb020bd5 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; @@ -60,6 +61,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..4fded6114 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Audit/AuditScreenTests.cs @@ -0,0 +1,257 @@ +using System.Net; +using FluentCMS.Core.Audit; +using FluentCMS.Core.Identity; +using FluentCMS.Infrastructure.Audit; +using FluentCMS.Infrastructure.Persistence; +using FluentCMS.IntegrationTests.Identity; +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(); + + await AssertAnonymousRequestsGoToSignInAsync(host); + } + + // An external provider adds a sign-in option, not a second way in: the admin's own sign-in page still answers every challenge. + [Fact] + public async Task With_an_external_provider_configured_anonymous_requests_still_go_to_sign_in() + { + await using var provider = await TestOpenIdProvider.StartAsync(); + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install, ["--environment", "Development"], new Dictionary + { + ["Authentication:OpenIdConnect:Authority"] = provider.Authority, + ["Authentication:OpenIdConnect:ClientId"] = TestOpenIdProvider.ClientId, + ["Authentication:OpenIdConnect:ClientSecret"] = TestOpenIdProvider.ClientSecret, + }); + await host.CreateFirstAdministratorAsync(); + + await AssertAnonymousRequestsGoToSignInAsync(host); + } + + private static async Task AssertAnonymousRequestsGoToSignInAsync(AdminHost host) + { + 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("