From ef0e63fd858f99ddbcd1bf01b238a7d58ab7842e Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 02:09:58 -0400 Subject: [PATCH] Add notification counts in the header and the security events view --- .../Components/Layout/MainLayout.razor | 2 +- .../Components/Layout/NotificationLinks.razor | 43 +++++ .../Components/Pages/Desk.razor | 5 +- .../Components/Pages/SecurityEventsView.razor | 148 ++++++++++++++++ src/FluentCMS.Core/Audit/AuditErrorCodes.cs | 5 + src/FluentCMS.Core/Audit/IAuditEventLists.cs | 51 ++++++ src/FluentCMS.Core/Audit/SecurityEvents.cs | 26 +++ src/FluentCMS.Core/Desk/DeskQueues.cs | 11 ++ src/FluentCMS.Core/Desk/IDeskQueues.cs | 3 + .../Audit/AuditEventLists.cs | 74 ++++++++ ...frastructureServiceCollectionExtensions.cs | 1 + .../Browser/AccessibilityTests.cs | 2 +- .../Desk/DeskScreenTests.cs | 2 +- .../Desk/SecurityEventTests.cs | 160 +++++++++++++++++ .../Admin/ChangeSetViewTests.cs | 2 + tests/FluentCMS.Tests/Admin/DeskTests.cs | 2 + .../Admin/SecurityEventsViewTests.cs | 162 ++++++++++++++++++ tests/FluentCMS.Tests/Desk/DeskQueuesTests.cs | 11 ++ 18 files changed, 706 insertions(+), 4 deletions(-) create mode 100644 src/FluentCMS.Admin/Components/Layout/NotificationLinks.razor create mode 100644 src/FluentCMS.Admin/Components/Pages/SecurityEventsView.razor create mode 100644 src/FluentCMS.Core/Audit/IAuditEventLists.cs create mode 100644 src/FluentCMS.Core/Audit/SecurityEvents.cs create mode 100644 src/FluentCMS.Infrastructure/Audit/AuditEventLists.cs create mode 100644 tests/FluentCMS.IntegrationTests/Desk/SecurityEventTests.cs create mode 100644 tests/FluentCMS.Tests/Admin/SecurityEventsViewTests.cs diff --git a/src/FluentCMS.Admin/Components/Layout/MainLayout.razor b/src/FluentCMS.Admin/Components/Layout/MainLayout.razor index ae30f29f7..f3918410b 100644 --- a/src/FluentCMS.Admin/Components/Layout/MainLayout.razor +++ b/src/FluentCMS.Admin/Components/Layout/MainLayout.razor @@ -8,7 +8,7 @@ FluentCMS @if (Initiator.Current is UserInitiator user) { - @L["Desk"] + @L["Audit log"] @L["Users"] @L["Agents"] diff --git a/src/FluentCMS.Admin/Components/Layout/NotificationLinks.razor b/src/FluentCMS.Admin/Components/Layout/NotificationLinks.razor new file mode 100644 index 000000000..bc7c36b39 --- /dev/null +++ b/src/FluentCMS.Admin/Components/Layout/NotificationLinks.razor @@ -0,0 +1,43 @@ +@using System.Globalization +@using FluentCMS.Core.Audit +@using FluentCMS.Core.Desk +@inject IDeskQueues Queues +@inject IAuditEventLists AuditLists +@inject TimeProvider Clock +@inject IStringLocalizer L + +@* The in-app notification counts (DSK-05), read on each page load: sets awaiting the person, and security events of the last day. *@ + + @L["Desk"] + @if (_awaiting is > 0) + { + @_awaiting.Value.ToString(CultureInfo.CurrentCulture) @L["awaiting you"] + } + + + @L["Security"] + @if (_security is > 0) + { + @_security.Value.ToString(CultureInfo.CurrentCulture) @L["in the last day"] + } + + +@code { + public static readonly TimeSpan SecurityWithin = TimeSpan.FromDays(1); + + private int? _awaiting; + private int? _security; + + protected override async Task OnInitializedAsync() + { + var awaiting = await Queues.CountAsync(DeskQueue.AwaitingMe); + _awaiting = awaiting.IsSuccess ? awaiting.Value : null; + + var security = await AuditLists.CountAsync(new AuditEventListQuery + { + Operations = SecurityEvents.Operations, + Since = Clock.GetUtcNow() - SecurityWithin, + }); + _security = security.IsSuccess ? security.Value : null; + } +} diff --git a/src/FluentCMS.Admin/Components/Pages/Desk.razor b/src/FluentCMS.Admin/Components/Pages/Desk.razor index f68e61968..bb86fb5ca 100644 --- a/src/FluentCMS.Admin/Components/Pages/Desk.razor +++ b/src/FluentCMS.Admin/Components/Pages/Desk.razor @@ -14,7 +14,10 @@

@L["Desk"]

@L["Change sets and facts that are waiting for a person."]

- + + @L["Security events"] + + @if (DoneMessage is { } done) { diff --git a/src/FluentCMS.Admin/Components/Pages/SecurityEventsView.razor b/src/FluentCMS.Admin/Components/Pages/SecurityEventsView.razor new file mode 100644 index 000000000..a16359528 --- /dev/null +++ b/src/FluentCMS.Admin/Components/Pages/SecurityEventsView.razor @@ -0,0 +1,148 @@ +@page "/security" +@using System.Globalization +@using FluentCMS.Core.Audit +@inject IAuditEventLists AuditLists +@inject IStringLocalizer L + +@L["Security events"] + +

@L["Security events"]

+

@L["Agent keys issued and revoked, caps exceeded, refused moves, changes to people's accounts and to routing rules, the newest first. Every one is also in the audit log."]

+ +@if (_error is not null) +{ + +} +else if (_total == 0) +{ +

@L["No security event is recorded."]

+} +else if (_events.Count == 0) +{ +

+ @L["This page is past the end of the list."] + @L["Go to the first page"] +

+} +else +{ +
+ + + + @L["When"] + @L["What"] + @L["Who"] + @L["About"] + + + + @foreach (var (auditEvent, index) in _events.Select((auditEvent, index) => (auditEvent, index))) + { + + + + + @Describe(auditEvent.Operation) + + + @switch (auditEvent.Initiator) + { + case AgentInitiator: + @L["Agent"] + break; + case SystemInitiator: + @L["System"] + break; + default: + @L["User"] + break; + } + @auditEvent.Initiator.DisplayName + + + + @if (auditEvent.SubjectType is { } type && auditEvent.SubjectId is { } id) + { + + @type @id.ToString("N")[..8] + + } + + + } + +
+
+ + +} + +@code { + public const int PageSize = 50; + + private IReadOnlyList _events = []; + private int _total; + private int _page; + private int _offset; + private string? _error; + + [SupplyParameterFromQuery(Name = "page")] + private int? PageNumber { get; set; } + + protected override async Task OnInitializedAsync() + { + _page = Math.Max(1, PageNumber ?? 1); + _offset = (int)Math.Min((_page - 1L) * PageSize, int.MaxValue - PageSize); + + var listed = await AuditLists.ListAsync(new AuditEventListQuery { Operations = SecurityEvents.Operations, Offset = _offset, Limit = PageSize }); + if (listed.IsSuccess) + { + (_events, _total) = (listed.Value.Items, listed.Value.Total); + } + else + { + // The error code is the resource key; the reader's own message is the text until a resource exists. + _error = string.Join(" ", listed.Errors.Select(error => + { + var localized = L[error.Code]; + return localized.ResourceNotFound ? error.Message : localized.Value; + })); + } + } + + private static string Href(int page) => page > 1 ? $"security?page={page}" : "security"; + + private string Describe(string operation) => operation switch + { + "agent_credential.issued" => L["An agent key was issued"], + "agent_credential.revoked" => L["An agent key was revoked"], + "agent.calls_per_period_exceeded" => L["An agent went over its calls cap"], + "agent.change_sets_per_day_exceeded" => L["An agent went over its change sets per day"], + "agent.items_per_change_set_exceeded" => L["An agent went over its items per change set"], + "agent.transition_refused" => L["An agent tried a move only people make"], + "user.created" => L["A person was added"], + "user.disabled" => L["A person was disabled"], + "user.enabled" => L["A person was enabled again"], + "user.password_reset_issued" => L["A password reset link was issued"], + "user.password_reset" => L["A password was reset"], + "user.login_linked" => L["An external sign-in was linked"], + "routing_rule.created" => L["A routing rule was created"], + "routing_rule.revised" => L["A routing rule was revised"], + "routing_rule.activated" => L["A routing rule was activated"], + "routing_rule.deactivated" => L["A routing rule was deactivated"], + _ => operation, + }; +} diff --git a/src/FluentCMS.Core/Audit/AuditErrorCodes.cs b/src/FluentCMS.Core/Audit/AuditErrorCodes.cs index 316824732..28942a9e2 100644 --- a/src/FluentCMS.Core/Audit/AuditErrorCodes.cs +++ b/src/FluentCMS.Core/Audit/AuditErrorCodes.cs @@ -6,4 +6,9 @@ public static class AuditErrorCodes public const string SubjectTypeMissing = "audit_subject_type_missing"; public const string SubjectIdMissing = "audit_subject_id_missing"; public const string ExportFormatUnknown = "audit_export_format_unknown"; + public const string OperationsMissing = "audit_operations_missing"; + + // The same codes the other lists of the desk use for a window out of range. + public const string OffsetInvalid = "offset_invalid"; + public const string LimitInvalid = "limit_invalid"; } diff --git a/src/FluentCMS.Core/Audit/IAuditEventLists.cs b/src/FluentCMS.Core/Audit/IAuditEventLists.cs new file mode 100644 index 000000000..169ad9a7f --- /dev/null +++ b/src/FluentCMS.Core/Audit/IAuditEventLists.cs @@ -0,0 +1,51 @@ +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Audit; + +// Pages of the log for screens, newest first, such as the desk's security events (SEC-08). IAuditReader streams the log oldest first +// for exports; this reads one page and the total. A list appends no event. +public interface IAuditEventLists +{ + Task> ListAsync(AuditEventListQuery query, CancellationToken cancellationToken = default); + + // How many events the query selects, whatever its offset and limit. + Task> CountAsync(AuditEventListQuery query, CancellationToken cancellationToken = default); +} + +public sealed record AuditEventList(IReadOnlyList Items, int Total); + +// The events with any of the given operations, optionally only those at or after a time, newest first. +public sealed record AuditEventListQuery +{ + public const int MaxLimit = 200; + + public required IReadOnlyCollection Operations { get; init; } + + public DateTimeOffset? Since { get; init; } + + public int Offset { get; init; } + + public int Limit { get; init; } = 50; + + public Result Validate() + { + var errors = new List(); + + if (Operations is null || Operations.Count == 0 || Operations.Any(string.IsNullOrWhiteSpace)) + { + errors.Add(new Error(AuditErrorCodes.OperationsMissing, "A list names one or more operations to list.")); + } + + if (Offset < 0) + { + errors.Add(new Error(AuditErrorCodes.OffsetInvalid, $"The offset is {Offset}; it is 0 or more.")); + } + + if (Limit is < 1 or > MaxLimit) + { + errors.Add(new Error(AuditErrorCodes.LimitInvalid, $"The limit is {Limit}; it is from 1 to {MaxLimit}.")); + } + + return errors.Count == 0 ? Result.Success() : Result.Failure(errors); + } +} diff --git a/src/FluentCMS.Core/Audit/SecurityEvents.cs b/src/FluentCMS.Core/Audit/SecurityEvents.cs new file mode 100644 index 000000000..0a71ada6e --- /dev/null +++ b/src/FluentCMS.Core/Audit/SecurityEvents.cs @@ -0,0 +1,26 @@ +namespace FluentCMS.Core.Audit; + +// The security-relevant operations the desk lists in its own view (SEC-08): agent credentials, caps and refused moves, people's +// accounts, and routing rules. Each is appended by the lane that owns it; an operation no code appends yet simply matches nothing. +public static class SecurityEvents +{ + public static IReadOnlyList Operations { get; } = + [ + "agent_credential.issued", + "agent_credential.revoked", + "agent.calls_per_period_exceeded", + "agent.change_sets_per_day_exceeded", + "agent.items_per_change_set_exceeded", + "agent.transition_refused", + "user.created", + "user.disabled", + "user.enabled", + "user.password_reset_issued", + "user.password_reset", + "user.login_linked", + "routing_rule.created", + "routing_rule.revised", + "routing_rule.activated", + "routing_rule.deactivated", + ]; +} diff --git a/src/FluentCMS.Core/Desk/DeskQueues.cs b/src/FluentCMS.Core/Desk/DeskQueues.cs index e720405c7..c470aaabf 100644 --- a/src/FluentCMS.Core/Desk/DeskQueues.cs +++ b/src/FluentCMS.Core/Desk/DeskQueues.cs @@ -47,6 +47,17 @@ public async Task>> CountAsync(Cancel return Result>.Success(counts); } + public async Task> CountAsync(DeskQueue queue, CancellationToken cancellationToken = default) + { + if (queue == DeskQueue.StaleFacts) + { + var stale = await facts.StaleAsync(cancellationToken: cancellationToken); + return stale.IsSuccess ? Result.Success(stale.Value.Count) : Result.Failure(stale.Errors); + } + + return await lists.CountAsync(Query(queue, clock.GetUtcNow()), cancellationToken); + } + public Task> ListChangeSetsAsync(DeskQueue queue, int offset, int limit, CancellationToken cancellationToken = default) { if (!ChangeSetQueues.Contains(queue)) diff --git a/src/FluentCMS.Core/Desk/IDeskQueues.cs b/src/FluentCMS.Core/Desk/IDeskQueues.cs index 73edc6344..acc85156f 100644 --- a/src/FluentCMS.Core/Desk/IDeskQueues.cs +++ b/src/FluentCMS.Core/Desk/IDeskQueues.cs @@ -21,6 +21,9 @@ public interface IDeskQueues // How many sets or facts each of the six queues holds now. Task>> CountAsync(CancellationToken cancellationToken = default); + // How many one queue holds, for a count shown on every page, such as awaiting me in the header (DSK-05). + Task> CountAsync(DeskQueue queue, CancellationToken cancellationToken = default); + // A page of a queue of change sets; stale facts is listed by ListStaleFactsAsync. Task> ListChangeSetsAsync(DeskQueue queue, int offset, int limit, CancellationToken cancellationToken = default); diff --git a/src/FluentCMS.Infrastructure/Audit/AuditEventLists.cs b/src/FluentCMS.Infrastructure/Audit/AuditEventLists.cs new file mode 100644 index 000000000..3a428a6c2 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Audit/AuditEventLists.cs @@ -0,0 +1,74 @@ +using System.Runtime.CompilerServices; +using FluentCMS.Core.Audit; +using FluentCMS.Core.Results; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Infrastructure.Audit; + +// SQL rather than LINQ because EF Core will not compare or order a DateTimeOffset on SQLite, which stores it as text; stored +// timestamps are all UTC, so the text sorts in time order, as in AuditReader. Every value travels as a parameter. +internal sealed class AuditEventLists(FluentCmsDbContext context) : IAuditEventLists +{ + public async Task> ListAsync(AuditEventListQuery query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + var valid = query.Validate(); + if (!valid.IsSuccess) + { + return Result.Failure(valid.Errors); + } + + var (filter, parameters) = Filter(query); + var limit = Parameter(parameters, query.Limit); + var offset = Parameter(parameters, query.Offset); + var rows = await context.AuditEvents + .FromSql(FormattableStringFactory.Create( + $"SELECT * FROM \"AuditEvents\" WHERE {filter} ORDER BY \"Timestamp\" DESC, \"Id\" DESC LIMIT {limit} OFFSET {offset}", + [.. parameters])) + .AsNoTracking() + .ToListAsync(cancellationToken); + + var total = await CountRowsAsync(query, cancellationToken); + return Result.Success(new AuditEventList([.. rows.Select(row => row.ToEvent())], total)); + } + + public async Task> CountAsync(AuditEventListQuery query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + var valid = query.Validate(); + return valid.IsSuccess + ? Result.Success(await CountRowsAsync(query, cancellationToken)) + : Result.Failure(valid.Errors); + } + + private Task CountRowsAsync(AuditEventListQuery query, CancellationToken cancellationToken) + { + var (filter, parameters) = Filter(query); + return context.AuditEvents + .FromSql(FormattableStringFactory.Create($"SELECT * FROM \"AuditEvents\" WHERE {filter}", [.. parameters])) + .CountAsync(cancellationToken); + } + + private static (string Filter, List Parameters) Filter(AuditEventListQuery query) + { + var parameters = new List(); + var operations = string.Join(", ", query.Operations.Distinct().Select(operation => Parameter(parameters, operation))); + var filter = $"\"Operation\" IN ({operations})"; + + if (query.Since is { } since) + { + filter += $" AND \"Timestamp\" >= {Parameter(parameters, since.ToUniversalTime())}"; + } + + return (filter, parameters); + } + + private static string Parameter(List parameters, object value) + { + parameters.Add(value); + return $"{{{parameters.Count - 1}}}"; + } +} diff --git a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs index 0c8aaf6ee..f3cef0749 100644 --- a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -80,6 +80,7 @@ private static void AddAudit(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } // FieldTypeRegistry comes from AddFluentCmsCore, so built-in and plugin field types reach the directory the same way. diff --git a/tests/FluentCMS.IntegrationTests/Browser/AccessibilityTests.cs b/tests/FluentCMS.IntegrationTests/Browser/AccessibilityTests.cs index 81a6f98bf..f0d5828ff 100644 --- a/tests/FluentCMS.IntegrationTests/Browser/AccessibilityTests.cs +++ b/tests/FluentCMS.IntegrationTests/Browser/AccessibilityTests.cs @@ -21,7 +21,7 @@ public async Task The_showcase_desk_and_set_view_have_no_wcag_21_aa_violations() var set = await AwaitingReviewAsync(session); await session.SignInWithKeyboardAsync(); - string[] paths = ["/ui", "/", "/?queue=stale-facts", $"/sets/{set.Id}?queue=awaiting-me"]; + string[] paths = ["/ui", "/", "/?queue=stale-facts", $"/sets/{set.Id}?queue=awaiting-me", "/security"]; var violations = new List(); foreach (var theme in new[] { "light", "dark" }) { diff --git a/tests/FluentCMS.IntegrationTests/Desk/DeskScreenTests.cs b/tests/FluentCMS.IntegrationTests/Desk/DeskScreenTests.cs index 6e2bc4f3f..45ad0d4a2 100644 --- a/tests/FluentCMS.IntegrationTests/Desk/DeskScreenTests.cs +++ b/tests/FluentCMS.IntegrationTests/Desk/DeskScreenTests.cs @@ -40,7 +40,7 @@ public async Task Signing_in_lands_on_the_desk_with_the_queues_and_their_counts( Assert.Contains("data-current-queue=\"awaiting-me\"", desk); Assert.Contains($"data-change-set=\"{waiting:N}\"", desk); Assert.Contains("Add the product type.", desk); - Assert.Matches("]*>Desk", desk); + Assert.Contains("data-testid=\"nav-desk\"", desk); var empty = await host.Client.GetStringAsync("/?queue=reverted"); Assert.Contains("data-current-queue=\"reverted\"", empty); diff --git a/tests/FluentCMS.IntegrationTests/Desk/SecurityEventTests.cs b/tests/FluentCMS.IntegrationTests/Desk/SecurityEventTests.cs new file mode 100644 index 000000000..f96e50f9f --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Desk/SecurityEventTests.cs @@ -0,0 +1,160 @@ +using System.Net; +using System.Text.RegularExpressions; +using FluentCMS.Core.Agents; +using FluentCMS.Core.Audit; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Sites; +using FluentCMS.Infrastructure.Audit; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests.Desk; + +public sealed partial class SecurityEventTests +{ + private static readonly DateTimeOffset Start = new(2026, 10, 1, 9, 0, 0, TimeSpan.Zero); + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_list_holds_the_named_operations_newest_first_with_the_total(string provider) + { + await using var database = await TemporaryDatabase.CreateAsync(provider); + await using var services = TestServices.Build(database.Configuration, collection => collection.AddSingleton(new TestInitiator(null))); + await TestServices.MigrateAsync(services); + await SeedAsync( + services, + Row(Start, "agent_credential.issued"), + Row(Start.AddHours(1), "change_set.opened"), + Row(Start.AddHours(2), "agent.calls_per_period_exceeded"), + Row(Start.AddHours(3), "user.disabled"), + Row(Start.AddHours(4), "agent_credential.revoked")); + + var all = await ListAsync(services, new AuditEventListQuery { Operations = SecurityEvents.Operations }); + var page = await ListAsync(services, new AuditEventListQuery { Operations = SecurityEvents.Operations, Offset = 1, Limit = 2 }); + var recent = await ListAsync(services, new AuditEventListQuery { Operations = SecurityEvents.Operations, Since = Start.AddHours(2).ToOffset(TimeSpan.FromHours(-5)) }); + + Assert.Equal( + ["agent_credential.revoked", "user.disabled", "agent.calls_per_period_exceeded", "agent_credential.issued"], + all.Items.Select(item => item.Operation)); + Assert.Equal(4, all.Total); + Assert.Equal(["user.disabled", "agent.calls_per_period_exceeded"], page.Items.Select(item => item.Operation)); + Assert.Equal(4, page.Total); + Assert.Equal(3, recent.Total); + Assert.Equal(Start.AddHours(4), all.Items[0].Timestamp); + } + + [Theory] + [InlineData(-1, 50, AuditErrorCodes.OffsetInvalid)] + [InlineData(0, 0, AuditErrorCodes.LimitInvalid)] + [InlineData(0, 201, AuditErrorCodes.LimitInvalid)] + public async Task A_window_out_of_range_or_no_operation_is_refused_and_a_list_appends_nothing(int offset, int limit, string code) + { + await using var database = await TemporaryDatabase.CreateAsync(DatabaseProviders.Sqlite); + await using var services = TestServices.Build(database.Configuration, collection => collection.AddSingleton(new TestInitiator(null))); + await TestServices.MigrateAsync(services); + await using var scope = services.CreateAsyncScope(); + var lists = scope.ServiceProvider.GetRequiredService(); + + var window = await lists.ListAsync(new AuditEventListQuery { Operations = SecurityEvents.Operations, Offset = offset, Limit = limit }); + var none = await lists.CountAsync(new AuditEventListQuery { Operations = [] }); + var listed = await lists.ListAsync(new AuditEventListQuery { Operations = SecurityEvents.Operations }); + + Assert.Equal(code, Assert.Single(window.Errors).Code); + Assert.Equal(AuditErrorCodes.OperationsMissing, Assert.Single(none.Errors).Code); + Assert.Equal(0, listed.Value.Total); + Assert.Equal(0, await scope.ServiceProvider.GetRequiredService().AuditEvents.CountAsync()); + } + + [Fact] + public async Task The_security_view_lists_key_cap_and_account_events_and_the_header_counts_them() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + var ownerId = await host.CreateFirstAdministratorAsync(); + var key = await host.AsUserAsync(ownerId, async credentials => + (await credentials.IssueAsync(new NewAgentCredential( + "catalog-sync", + DateTimeOffset.UtcNow.AddDays(30), + AgentScope.Create([Site.Default.Id], null, null, [AgentOperation.Read]).Value, + AgentCaps.Create(10, TimeSpan.FromHours(1), 1m, TimeSpan.FromDays(1), 10, 10).Value))).Value); + await host.AsUserAsync(ownerId, async credentials => + (await credentials.RevokeAsync(key.Credential.Id)).IsSuccess); + await AwaitingReviewAsync(host, ownerId); + await host.SignInAsync(AdminHost.Owner); + + var page = await host.Client.GetStringAsync("/security"); + var desk = await host.Client.GetStringAsync("/"); + + Assert.Equal( + ["agent_credential.revoked", "agent_credential.issued"], + EventAttribute().Matches(page).Select(match => match.Groups[1].Value)); + Assert.Contains("An agent key was revoked", page); + Assert.Contains($"href=\"audit?type=agent_credential&id={key.Credential.Id}\"", page); + Assert.DoesNotContain("change_set.opened", page); + Assert.Matches("data-testid=\"nav-desk\"[^>]*>\\s*Desk\\s*]*data-count=\"1\"", desk); + Assert.Matches("data-testid=\"nav-security\"[^>]*>\\s*Security\\s*]*data-count=\"2\"", desk); + Assert.Contains("href=\"security\"", desk); + } + + [Fact] + public async Task An_anonymous_request_for_the_security_view_is_sent_to_sign_in() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + await host.CreateFirstAdministratorAsync(); + + var response = await host.Client.GetAsync("/security"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("/sign-in?ReturnUrl=%2Fsecurity", host.LocationOf(response)); + } + + private static async Task AwaitingReviewAsync(AdminHost host, Guid ownerId) + { + var type = await host.AsUserAsync(ownerId, async directory => + (await directory.CreateAsync(new NewContentType("Product", "product", new ContentTypeDefinition([new FieldDefinition("title", "text")])))).Value); + await host.AsUserAsync(ownerId, async directory => + { + var set = (await directory.OpenAsync(new NewChangeSet("Add the product type.", []))).Value; + await directory.AddOperationAsync(set.Id, ChangeOperation.Create(OperationKind.Create, ItemKind.Schema, type.Id, null, 1).Value); + await directory.TransitionAsync(set.Id, ChangeSetState.ChecksRunning); + return (await directory.TransitionAsync(set.Id, ChangeSetState.AwaitingReview)).Value; + }); + } + + private static async Task ListAsync(ServiceProvider services, AuditEventListQuery query) + { + await using var scope = services.CreateAsyncScope(); + return (await scope.ServiceProvider.GetRequiredService().ListAsync(query)).Value; + } + + private static async Task SeedAsync(ServiceProvider services, params AuditEventRow[] rows) + { + await using var scope = services.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + context.AuditEvents.AddRange(rows); + await context.SaveChangesAsync(); + } + + private static AuditEventRow Row(DateTimeOffset timestamp, string operation) + { + return new AuditEventRow + { + Id = Guid.CreateVersion7(), + Timestamp = timestamp, + InitiatorKind = InitiatorKinds.User, + InitiatorId = Guid.NewGuid(), + InitiatorName = "Dana", + Operation = operation, + SubjectType = "agent_credential", + SubjectId = Guid.NewGuid(), + }; + } + + [GeneratedRegex("data-event=\"([a-z_.]+)\"")] + private static partial Regex EventAttribute(); +} diff --git a/tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs b/tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs index 3ff812b0a..5f85b0f4c 100644 --- a/tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs +++ b/tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs @@ -320,6 +320,8 @@ private sealed class QueueAfter(FakeSets sets) : IDeskQueues public Task>> CountAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> CountAsync(DeskQueue queue, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> ListChangeSetsAsync(DeskQueue queue, int offset, int limit, CancellationToken cancellationToken = default) { var current = new ChangeSetSummary(sets.Set.Id, "This set.", sets.Set.Initiator, ChangeSetState.AwaitingReview, 2, null, Opened, Opened); diff --git a/tests/FluentCMS.Tests/Admin/DeskTests.cs b/tests/FluentCMS.Tests/Admin/DeskTests.cs index e5d753d0f..341d37e83 100644 --- a/tests/FluentCMS.Tests/Admin/DeskTests.cs +++ b/tests/FluentCMS.Tests/Admin/DeskTests.cs @@ -253,6 +253,8 @@ public Task>> CountAsync(Cancellation return Task.FromResult(Result>.Success(counts)); } + public Task> CountAsync(DeskQueue queue, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> ListChangeSetsAsync(DeskQueue queue, int offset, int limit, CancellationToken cancellationToken = default) { SetPages.Add((queue, offset, limit)); diff --git a/tests/FluentCMS.Tests/Admin/SecurityEventsViewTests.cs b/tests/FluentCMS.Tests/Admin/SecurityEventsViewTests.cs new file mode 100644 index 000000000..6a8c16a2e --- /dev/null +++ b/tests/FluentCMS.Tests/Admin/SecurityEventsViewTests.cs @@ -0,0 +1,162 @@ +using Bunit; +using FluentCMS.Admin.Components.Layout; +using FluentCMS.Admin.Components.Pages; +using FluentCMS.Core.Audit; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Desk; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Results; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Tests.Admin; + +public sealed class SecurityEventsViewTests +{ + private static readonly DateTimeOffset Now = new(2026, 10, 18, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void Lists_each_event_with_what_happened_who_did_it_and_what_it_was_about() + { + var lists = new FakeLists(); + using var context = Context(lists); + + var cut = context.Render(); + + var rows = cut.FindAll("[data-event]"); + Assert.Equal(["agent_credential.revoked", "agent.calls_per_period_exceeded", "routing_rule.activated"], rows.Select(row => row.GetAttribute("data-event"))); + Assert.Contains("An agent key was revoked", rows[0].TextContent); + Assert.Equal("user", rows[0].QuerySelector("[data-initiator]")!.GetAttribute("data-initiator")); + Assert.Equal("agent", rows[1].QuerySelector("[data-initiator]")!.GetAttribute("data-initiator")); + Assert.Equal($"audit?type=agent_credential&id={lists.Subject}", rows[0].QuerySelector("a")!.GetAttribute("href")); + Assert.Equal("2026-10-18 12:00:00 UTC", rows[0].QuerySelector("time")!.TextContent); + Assert.Equal(SecurityEvents.Operations, Assert.Single(lists.Queries).Operations); + Assert.Equal((0, SecurityEventsView.PageSize), (lists.Queries[0].Offset, lists.Queries[0].Limit)); + Assert.Equal("rove", cut.Find("tbody").GetAttribute("data-ui")); + } + + [Fact] + public void The_page_comes_from_the_address_and_links_to_its_neighbours() + { + var lists = new FakeLists { Total = 160 }; + using var context = Context(lists); + context.Services.GetRequiredService().NavigateTo("/security?page=2"); + + var cut = context.Render(); + + Assert.Equal(50, lists.Queries[0].Offset); + Assert.Contains("51 to 53 of 160", cut.Find("[data-testid=security-pages]").TextContent); + Assert.Equal("security", cut.Find("a[rel=prev]").GetAttribute("href")); + Assert.Equal("security?page=3", cut.Find("a[rel=next]").GetAttribute("href")); + } + + [Fact] + public void An_empty_log_says_so() + { + using var context = Context(new FakeLists { Total = 0, Empty = true }); + + var cut = context.Render(); + + Assert.NotNull(cut.Find("[data-testid=security-empty]")); + Assert.Empty(cut.FindAll("table")); + } + + [Fact] + public void The_header_shows_the_awaiting_and_security_counts_only_when_there_is_something() + { + var lists = new FakeLists(); + var queues = new CountingQueues { AwaitingMe = 4 }; + using var context = Context(lists, queues); + + var cut = context.Render(); + + Assert.Equal("4", cut.Find("[data-testid=nav-desk] [data-count]").GetAttribute("data-count")); + Assert.Equal("3", cut.Find("[data-testid=nav-security] [data-count]").GetAttribute("data-count")); + var query = Assert.Single(lists.Counted); + Assert.Equal(Now.AddDays(-1), query.Since); + + queues.AwaitingMe = 0; + lists.Total = 0; + var quiet = context.Render(); + Assert.Empty(quiet.FindAll("[data-count]")); + } + + private static AdminTestContext Context(FakeLists lists, CountingQueues? queues = null) + { + var context = new AdminTestContext(); + context.Services.AddSingleton(lists); + context.Services.AddSingleton(queues ?? new CountingQueues()); + context.Services.AddSingleton(new FixedClock(Now)); + return context; + } + + private sealed class FakeLists : IAuditEventLists + { + public Guid Subject { get; } = Guid.NewGuid(); + + public int Total { get; set; } = 3; + + public bool Empty { get; init; } + + public List Queries { get; } = []; + + public List Counted { get; } = []; + + public Task> ListAsync(AuditEventListQuery query, CancellationToken cancellationToken = default) + { + Queries.Add(query); + var person = new UserInitiator(Guid.NewGuid(), "Dana"); + var agent = new AgentInitiator(Guid.NewGuid(), "catalog-sync"); + AuditEvent[] events = Empty + ? [] + : + [ + Event("agent_credential.revoked", person, 0), + Event("agent.calls_per_period_exceeded", agent, -5), + Event("routing_rule.activated", person, -10), + ]; + return Task.FromResult(Result.Success(new AuditEventList(events, Total))); + } + + public Task> CountAsync(AuditEventListQuery query, CancellationToken cancellationToken = default) + { + Counted.Add(query); + return Task.FromResult(Result.Success(Total)); + } + + private AuditEvent Event(string operation, Initiator initiator, int minutes) + { + return new AuditEvent + { + Id = Guid.NewGuid(), + Timestamp = Now.AddMinutes(minutes), + Initiator = initiator, + Operation = operation, + SubjectType = "agent_credential", + SubjectId = Subject, + }; + } + } + + private sealed class CountingQueues : IDeskQueues + { + public int AwaitingMe { get; set; } + + public Task> CountAsync(DeskQueue queue, CancellationToken cancellationToken = default) + { + Assert.Equal(DeskQueue.AwaitingMe, queue); + return Task.FromResult(Result.Success(AwaitingMe)); + } + + public Task>> CountAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> ListChangeSetsAsync(DeskQueue queue, int offset, int limit, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> ListStaleFactsAsync(int offset, int limit, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } + + private sealed class FixedClock(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } +} diff --git a/tests/FluentCMS.Tests/Desk/DeskQueuesTests.cs b/tests/FluentCMS.Tests/Desk/DeskQueuesTests.cs index eefe69ee2..266662111 100644 --- a/tests/FluentCMS.Tests/Desk/DeskQueuesTests.cs +++ b/tests/FluentCMS.Tests/Desk/DeskQueuesTests.cs @@ -60,6 +60,17 @@ public async Task Counts_every_queue_and_the_stale_facts() Assert.All(_lists.Counted.Where(query => query.States.Contains(ChangeSetState.Published)), query => Assert.Equal(Now.AddDays(-7), query.EnteredSince)); } + [Fact] + public async Task One_queue_is_counted_on_its_own() + { + _lists.Counts[ChangeSetState.AwaitingReview] = 3; + _facts.Stale = [Fact()]; + + Assert.Equal(3, (await Queues.CountAsync(DeskQueue.AwaitingMe)).Value); + Assert.Equal(1, (await Queues.CountAsync(DeskQueue.StaleFacts)).Value); + Assert.Equal([ChangeSetState.AwaitingReview], Assert.Single(_lists.Counted).States); + } + [Fact] public async Task Stale_facts_are_paged_in_their_order() {