< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Auditing.AuditingCommandHandler<T1, T2>
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Auditing/AuditingCommandHandler.cs
Line coverage
100%
Covered lines: 36
Uncovered lines: 0
Coverable lines: 36
Total lines: 103
Line coverage: 100%
Branch coverage
94%
Covered branches: 17
Total branches: 18
Branch coverage: 94.4%
Method coverage
100%
Covered methods: 4
Fully covered methods: 4
Total methods: 4
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()100%1010100%
TryWriteAsync()100%11100%
ResolveTargetId(...)87.5%88100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Auditing/AuditingCommandHandler.cs

#LineLine coverage
 1using ArturRios.Heimdall.Command.Services;
 2using ArturRios.Heimdall.Shared.Messages;
 3using ArturRios.Mediator.Command;
 4using ArturRios.Mediator.Command.Interfaces;
 5using ArturRios.Output;
 6using Microsoft.Extensions.Logging;
 7
 8namespace ArturRios.Heimdall.Command.Auditing;
 9
 10/// <summary>
 11///     Wraps a command handler so every attempted write produces one audit trail entry (NFR-09),
 12///     without changing the wrapped handler — including one that throws instead of answering, which is
 13///     recorded as a refusal and rethrown — plus one more for each side effect a successful
 14///     command's output reports through <see cref="IAuditedSideEffects" />. Registered per-handler by
 15///     <see cref="CommandHandlerRegistrationExtensions.AddAuditedCommandHandler{TCommand,TOutput,THandler}" />.
 16/// </summary>
 104217public class AuditingCommandHandler<TCommand, TOutput>(
 104218    ICommandHandlerAsync<TCommand, TOutput> inner,
 104219    IAuditLogWriter auditLogWriter,
 104220    ILogger<AuditingCommandHandler<TCommand, TOutput>> logger)
 21    : ICommandHandlerAsync<TCommand, TOutput>
 22    where TCommand : BaseCommand
 23    where TOutput : CommandOutput
 24{
 25    public async Task<DataOutput<TOutput?>> HandleAsync(TCommand command, CancellationToken cancellationToken = default)
 26    {
 104227        var action = typeof(TCommand).Name;
 28
 29        DataOutput<TOutput?> result;
 30
 31        try
 32        {
 104233            result = await inner.HandleAsync(command, cancellationToken);
 103334        }
 935        catch (Exception exception)
 36        {
 37            // An attempt that threw is still an attempt (NFR-09). The hash gate's refusal is the one
 38            // that matters most: PasswordHashSaturationFilter turns it into a 503 outside this
 39            // decorator, so without an entry here a shed login leaves nothing and the
 40            // CREDENTIAL_VERIFICATION_SHEDDING signal, which counts entries with that reason, never
 41            // fires. Any other exception records one fixed reason — its own message can carry
 42            // provider text or caller input — and is rethrown unchanged for whatever handles it.
 943            await TryWriteAsync(
 944                action, null, false,
 945                exception is PasswordHashGateSaturatedException
 946                    ? AuthMessages.AuthenticationTemporarilyUnavailable
 947                    : AuditMessages.CommandFailed);
 48
 949            throw;
 50        }
 51
 103352        var targetId = ResolveTargetId(result.Data);
 53
 54        // Refusals are recorded too, and they are usually the entries worth having: a caller
 55        // repeatedly denied a scope they do not own, or repeatedly failing a password, leaves no
 56        // other trace anywhere. Only the first error is kept — enough to say why, and all of them
 57        // are the application's own canonical messages, so nothing a caller submitted is written
 58        // here.
 103359        await TryWriteAsync(
 103360            action, targetId, result.Success, result.Success ? null : result.Errors.FirstOrDefault());
 61
 62        // A side effect only happened if the command did, so a refusal records none.
 103363        if (result.Success && result.Data is IAuditedSideEffects sideEffects)
 64        {
 3665            foreach (var sideEffect in sideEffects.AuditedSideEffects())
 66            {
 667                await TryWriteAsync(sideEffect, targetId, true, null);
 68            }
 69        }
 70
 103371        return result;
 103372    }
 73
 74    private async Task TryWriteAsync(string action, Guid? targetId, bool succeeded, string? failureReason)
 75    {
 76        try
 77        {
 104878            await auditLogWriter.WriteAsync(action, targetId, succeeded, failureReason);
 103979        }
 980        catch (Exception exception)
 81        {
 82            // The trail is a record of what the API did, not part of doing it: a caller whose
 83            // request succeeded is not told it failed because the entry could not be written.
 984            logger.LogWarning(
 985                exception, "Failed to write audit log entry for {Action} (target {TargetId})",
 986                action, targetId);
 987        }
 104888    }
 89
 90    private static Guid? ResolveTargetId(TOutput? output)
 91    {
 103392        if (output is null)
 93        {
 55994            return null;
 95        }
 96
 47497        var property = typeof(TOutput).GetProperty("Id") ?? typeof(TOutput).GetProperty("PublicId");
 98
 47499        return property is not null && property.PropertyType == typeof(Guid)
 474100            ? (Guid?)property.GetValue(output)
 474101            : null;
 102    }
 103}