< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Query.Handlers.ListAuditLogQueryHandler
Assembly: ArturRios.Heimdall.Query
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Query/Handlers/ListAuditLogQueryHandler.cs
Line coverage
100%
Covered lines: 40
Uncovered lines: 0
Coverable lines: 40
Total lines: 106
Line coverage: 100%
Branch coverage
100%
Covered branches: 14
Total branches: 14
Branch coverage: 100%
Method coverage
100%
Covered methods: 2
Fully covered methods: 2
Total methods: 2
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()100%1414100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Query/Handlers/ListAuditLogQueryHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Domain.Entities;
 3using ArturRios.Heimdall.Query.Input;
 4using ArturRios.Heimdall.Query.Output;
 5using ArturRios.Heimdall.Shared.Messages;
 6using ArturRios.Heimdall.Shared.Time;
 7using ArturRios.Mediator.Query.Interfaces;
 8using ArturRios.Output;
 9using FluentValidation;
 10
 11namespace ArturRios.Heimdall.Query.Handlers;
 12
 13/// <summary>
 14///     Handles <see cref="ListAuditLogQuery" /> (UC-48, FR-AT-01/02): the audit trail, newest first,
 15///     filtered by recording time, actor, action, target and outcome.
 16/// </summary>
 17/// <remarks>
 18///     <para>
 19///         Authorization is entirely the endpoint's: the only actor is the System Admin, and the
 20///         <c>RoleRequirement</c> settles it. There is no data-dependent rule left, because the trail
 21///         has no scope column to scope a Scope Admin's view by.
 22///     </para>
 23///     <para>
 24///         Entries are projected as stored and nothing else is read — no join to <c>PERSON</c>, no
 25///         inference from neighbouring entries. An attribution cleared under NFR-21 stays cleared
 26///         here (Data Retention Schedule §7.1); reading the trail must not undo what retention did.
 27///     </para>
 28/// </remarks>
 3829public class ListAuditLogQueryHandler(
 3830    IAsyncReadOnlyRepository<AuditLog, long> auditReader,
 3831    IValidator<ListAuditLogQuery> validator)
 32    : IPaginatedQueryHandlerAsync<ListAuditLogQuery, AuditLogEntryOutput>
 33{
 34    public async Task<PaginatedOutput<AuditLogEntryOutput>> HandleAsync(
 35        ListAuditLogQuery query, CancellationToken cancellationToken = default)
 36    {
 3837        var output = PaginatedOutput<AuditLogEntryOutput>.New;
 38
 39        // AF-48a (NFR-10): the range, the action's length and the page, before any query runs.
 3840        var validation = await validator.ValidateAsync(query, cancellationToken);
 41
 3842        if (!validation.IsValid)
 43        {
 844            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 45        }
 46
 3447        var entries = auditReader.Query();
 48
 3449        if (query.From is { } from)
 50        {
 651            var fromUtc = UtcInstant.Of(from);
 652            entries = entries.Where(x => x.CreatedAt >= fromUtc);
 53        }
 54
 3455        if (query.To is { } to)
 56        {
 357            var toUtc = UtcInstant.Of(to);
 358            entries = entries.Where(x => x.CreatedAt <= toUtc);
 59        }
 60
 3461        if (query.ActorId is { } actorId)
 62        {
 363            entries = entries.Where(x => x.ActorPersonId == actorId);
 64        }
 65
 66        // Exact, as recorded. Blank means no filter — what a cleared text box submits.
 3467        if (!string.IsNullOrWhiteSpace(query.Action))
 68        {
 869            var action = query.Action;
 870            entries = entries.Where(x => x.Action == action);
 71        }
 72
 3473        if (query.TargetId is { } targetId)
 74        {
 1475            entries = entries.Where(x => x.TargetId == targetId);
 76        }
 77
 3478        if (query.Succeeded is { } succeeded)
 79        {
 380            entries = entries.Where(x => x.Succeeded == succeeded);
 81        }
 82
 83        // Newest first, with the internal id as tiebreaker: a command and its audited side effects
 84        // share an instant often enough that, without it, a page boundary between them could show
 85        // one entry twice and another never. Ordered on the entity, since the id never reaches the
 86        // output, then projected and paginated over that ordering.
 3487        var projected = entries
 3488            .OrderByDescending(x => x.CreatedAt)
 3489            .ThenByDescending(x => x.Id)
 3490            .Select(x => new AuditLogEntryOutput
 3491            {
 3492                Id = x.PublicId,
 3493                RecordedAt = x.CreatedAt,
 3494                ActorId = x.ActorPersonId,
 3495                ActorRole = x.ActorRole,
 3496                Action = x.Action,
 3497                TargetId = x.TargetId,
 3498                Succeeded = x.Succeeded,
 3499                FailureReason = x.FailureReason
 34100            });
 101
 34102        var page = await projected.PaginateAsync(query.PageNumber, query.PageSize, orderBy: null);
 103
 34104        return page.WithMessage(AuditMessages.AuditLogRetrieved);
 38105    }
 106}