< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.PurgeExpiredTokensCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/PurgeExpiredTokensCommandHandler.cs
Line coverage
98%
Covered lines: 78
Uncovered lines: 1
Coverable lines: 79
Total lines: 180
Line coverage: 98.7%
Branch coverage
62%
Covered branches: 5
Total branches: 8
Branch coverage: 62.5%
Method coverage
100%
Covered methods: 3
Fully covered methods: 2
Total methods: 3
Method coverage: 100%
Full method coverage: 66.6%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()50%2298.14%
PurgeAsync()66.66%66100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/PurgeExpiredTokensCommandHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Entities;
 2using ArturRios.Data.Relational.Core.Interfaces;
 3using ArturRios.Heimdall.Command.Input;
 4using ArturRios.Heimdall.Command.Output;
 5using ArturRios.Heimdall.Domain.Entities;
 6using ArturRios.Heimdall.Shared.Messages;
 7using ArturRios.Heimdall.Shared.Retention;
 8using ArturRios.Mediator.Command.Interfaces;
 9using ArturRios.Output;
 10using Microsoft.EntityFrameworkCore;
 11
 12namespace ArturRios.Heimdall.Command.Handlers;
 13
 14/// <summary>
 15///     Handles <see cref="PurgeExpiredTokensCommand" /> (NFR-19): removes password reset tokens,
 16///     email verification tokens, two-factor email codes, unconfirmed pending registrations (UC-47),
 17///     spent application credentials (UC-55) and ended registration-attempt notice budgets (FR-SR-14)
 18///     whose retention period has run out. All failures
 19///     are returned as errors on the <see cref="DataOutput{T}" /> rather than thrown.
 20/// </summary>
 21/// <remarks>
 22///     <para>
 23///         <b>What makes a row purgeable.</b> The four token and registration tables purge on
 24///         expiry: <c>ExpiresAt &lt;= now - grace</c>. It covers a used row and an expired-unused
 25///         row alike, and it is the only condition that can be applied to all four: neither
 26///         <see cref="PasswordResetToken" /> nor <see cref="EmailVerificationToken" /> records when
 27///         it was consumed, and only <see cref="TwoFactorEmailCode" /> carries a <c>CreatedAt</c>.
 28///         Expiry is a sound proxy in any case — each of these is issued with a lifetime measured
 29///         in minutes or hours, so a row's expiry is never far from its issue. A
 30///         <see cref="PendingRegistration" /> is held for the same grace period, so that following
 31///         a stale confirmation link answers "expired" rather than "invalid" for as long as it does
 32///         for the tokens.
 33///     </para>
 34///     <para>
 35///         An <see cref="ApplicationCredential" /> purges on a condition of its own: revoked or
 36///         expired at or before <c>now - grace</c>, whichever came first. Expiry alone would not
 37///         do — a credential may have none, and would then be kept for ever once revoked.
 38///     </para>
 39///     <para>
 40///         An <see cref="AttemptNoticeBudget" /> purges once its window ended at or before the
 41///         cutoff. An ended window counts nothing — the next notice starts a new one — so the row is
 42///         only a digest of an address waiting to be removed, and the grace keeps it no longer than a
 43///         token is kept.
 44///     </para>
 45///     <para>
 46///         <b>Why a live token is never at risk.</b> The cutoff is strictly in the past, so a token
 47///         that has not yet expired cannot match however the grace period is configured — the
 48///         options type refuses a zero or negative one. A person mid-recovery is never affected by
 49///         a run.
 50///     </para>
 51///     <para>
 52///         <b>Running on more than one instance.</b> NFR-06 requires that no instance assume it is
 53///         alone, and two purges may overlap. Nothing is claimed or locked: each run selects a
 54///         batch, and <c>DeleteRangeAsync</c> re-reads those ids and removes what still exists, so
 55///         an instance whose rows another already deleted simply removes fewer than it selected and
 56///         reports the true count. That is the whole of the coordination, and it is enough because
 57///         deleting an already-deleted row is the one concurrent outcome this pass can have.
 58///     </para>
 59/// </remarks>
 1360public class PurgeExpiredTokensCommandHandler(
 1361    IAsyncReadOnlyRepository<PasswordResetToken, long> passwordResetTokenReader,
 1362    IAsyncRepository<PasswordResetToken, long> passwordResetTokenWriter,
 1363    IAsyncReadOnlyRepository<EmailVerificationToken, long> emailVerificationTokenReader,
 1364    IAsyncRepository<EmailVerificationToken, long> emailVerificationTokenWriter,
 1365    IAsyncReadOnlyRepository<TwoFactorEmailCode, long> twoFactorEmailCodeReader,
 1366    IAsyncRepository<TwoFactorEmailCode, long> twoFactorEmailCodeWriter,
 1367    IAsyncReadOnlyRepository<PendingRegistration, long> pendingRegistrationReader,
 1368    IAsyncRepository<PendingRegistration, long> pendingRegistrationWriter,
 1369    IAsyncReadOnlyRepository<ApplicationCredential, long> applicationCredentialReader,
 1370    IAsyncRepository<ApplicationCredential, long> applicationCredentialWriter,
 1371    IAsyncReadOnlyRepository<AttemptNoticeBudget, long> attemptNoticeBudgetReader,
 1372    IAsyncRepository<AttemptNoticeBudget, long> attemptNoticeBudgetWriter,
 1373    DataRetentionOptions retention)
 74    : ICommandHandlerAsync<PurgeExpiredTokensCommand, PurgeExpiredTokensCommandOutput>
 75{
 76    public async Task<DataOutput<PurgeExpiredTokensCommandOutput?>> HandleAsync(PurgeExpiredTokensCommand command, Cance
 77    {
 1578        var output = DataOutput<PurgeExpiredTokensCommandOutput?>.New;
 1579        var cutoff = DateTime.UtcNow - retention.SingleUseTokenGrace;
 80
 81        // Oldest first, so a backlog drains in the order it accumulated and no row can be starved by
 82        // fresher ones arriving ahead of it.
 1583        var passwordResetTokens = await PurgeAsync(
 1584            passwordResetTokenReader.Query()
 1585                .Where(token => token.ExpiresAt <= cutoff)
 1586                .OrderBy(token => token.ExpiresAt),
 1587            passwordResetTokenWriter);
 88
 1589        var emailVerificationTokens = await PurgeAsync(
 1590            emailVerificationTokenReader.Query()
 1591                .Where(token => token.ExpiresAt <= cutoff)
 1592                .OrderBy(token => token.ExpiresAt),
 1593            emailVerificationTokenWriter);
 94
 1595        var twoFactorEmailCodes = await PurgeAsync(
 1596            twoFactorEmailCodeReader.Query()
 1597                .Where(code => code.ExpiresAt <= cutoff)
 1598                .OrderBy(code => code.ExpiresAt),
 1599            twoFactorEmailCodeWriter);
 100
 15101        var pendingRegistrations = await PurgeAsync(
 15102            pendingRegistrationReader.Query()
 15103                .Where(registration => registration.ExpiresAt <= cutoff)
 15104                .OrderBy(registration => registration.ExpiresAt),
 15105            pendingRegistrationWriter);
 106
 107        // UC-55: a revoked or expired credential can never be accepted again, so after the same
 108        // grace as the single-use tokens it is removed — revoked or expired, whichever came first.
 15109        var applicationCredentials = await PurgeAsync(
 15110            applicationCredentialReader.Query()
 15111                .Where(credential => (credential.RevokedAt != null && credential.RevokedAt <= cutoff) ||
 15112                                     (credential.ExpiresAt != null && credential.ExpiresAt <= cutoff))
 15113                .OrderBy(credential => credential.RevokedAt ?? credential.ExpiresAt),
 15114            applicationCredentialWriter);
 115
 116        // FR-SR-14: a notice budget whose window has ended counts nothing any more.
 15117        var attemptNoticeBudgets = await PurgeAsync(
 15118            attemptNoticeBudgetReader.Query()
 15119                .Where(budget => budget.WindowEndsAt <= cutoff)
 15120                .OrderBy(budget => budget.WindowEndsAt),
 15121            attemptNoticeBudgetWriter);
 122
 123        // Every table is attempted before the run reports, so one failing table does not hide
 124        // whether the others were cleared — and the next run retries whatever was missed.
 15125        var errors = passwordResetTokens.Errors
 15126            .Concat(emailVerificationTokens.Errors)
 15127            .Concat(twoFactorEmailCodes.Errors)
 15128            .Concat(pendingRegistrations.Errors)
 15129            .Concat(applicationCredentials.Errors)
 15130            .Concat(attemptNoticeBudgets.Errors)
 15131            .ToList();
 132
 15133        if (errors.Count > 0)
 134        {
 0135            return output.WithErrors(errors);
 136        }
 137
 138        // A run that removed nothing is a success: most runs find nothing to do, and a failed
 139        // outcome would fill the audit trail with refusals that never happened.
 15140        return output
 15141            .WithData(new PurgeExpiredTokensCommandOutput
 15142            {
 15143                PasswordResetTokensPurged = passwordResetTokens.Count,
 15144                EmailVerificationTokensPurged = emailVerificationTokens.Count,
 15145                TwoFactorEmailCodesPurged = twoFactorEmailCodes.Count,
 15146                PendingRegistrationsPurged = pendingRegistrations.Count,
 15147                ApplicationCredentialsPurged = applicationCredentials.Count,
 15148                AttemptNoticeBudgetsPurged = attemptNoticeBudgets.Count
 15149            })
 15150            .WithMessage(RetentionMessages.ExpiredTokensPurged);
 15151    }
 152
 153    /// <summary>
 154    ///     Removes at most <c>PurgeBatchSize</c> of the candidates and reports how many rows were
 155    ///     actually removed, or the persistence errors if the delete failed.
 156    /// </summary>
 157    private async Task<(int Count, IEnumerable<string> Errors)> PurgeAsync<T>(
 158        IQueryable<T> candidates,
 159        IAsyncRepository<T, long> writer) where T : Entity<long>
 160    {
 90161        var ids = await candidates
 90162            .Take(retention.PurgeBatchSize)
 90163            .Select(entity => entity.Id)
 90164            .ToListAsync();
 165
 90166        if (ids.Count == 0)
 167        {
 75168            return (0, []);
 169        }
 170
 15171        var deletion = await writer.DeleteRangeAsync(ids);
 172
 173        // The count comes from what the delete reported removing, not from what was selected: they
 174        // differ when another instance got there first, and the trail should record what this run
 175        // did rather than what it intended.
 15176        return deletion.Success
 15177            ? (deletion.Data?.Count() ?? 0, [])
 15178            : (0, deletion.Errors);
 90179    }
 180}

Methods/Properties

.ctor(ArturRios.Data.Relational.Core.Interfaces.IAsyncReadOnlyRepository`2<ArturRios.Heimdall.Domain.Entities.PasswordResetToken,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncRepository`2<ArturRios.Heimdall.Domain.Entities.PasswordResetToken,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncReadOnlyRepository`2<ArturRios.Heimdall.Domain.Entities.EmailVerificationToken,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncRepository`2<ArturRios.Heimdall.Domain.Entities.EmailVerificationToken,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncReadOnlyRepository`2<ArturRios.Heimdall.Domain.Entities.TwoFactorEmailCode,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncRepository`2<ArturRios.Heimdall.Domain.Entities.TwoFactorEmailCode,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncReadOnlyRepository`2<ArturRios.Heimdall.Domain.Entities.PendingRegistration,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncRepository`2<ArturRios.Heimdall.Domain.Entities.PendingRegistration,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncReadOnlyRepository`2<ArturRios.Heimdall.Domain.Entities.ApplicationCredential,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncRepository`2<ArturRios.Heimdall.Domain.Entities.ApplicationCredential,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncReadOnlyRepository`2<ArturRios.Heimdall.Domain.Entities.AttemptNoticeBudget,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncRepository`2<ArturRios.Heimdall.Domain.Entities.AttemptNoticeBudget,System.Int64>,ArturRios.Heimdall.Shared.Retention.DataRetentionOptions)
HandleAsync()
PurgeAsync()