< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.VerifyEmailCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/VerifyEmailCommandHandler.cs
Line coverage
95%
Covered lines: 68
Uncovered lines: 3
Coverable lines: 71
Total lines: 224
Line coverage: 95.7%
Branch coverage
100%
Covered branches: 26
Total branches: 26
Branch coverage: 100%
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()95.45%222297.77%
RetireOtherTokensAsync()100%4482.35%

File(s)

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

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Command.Input;
 3using ArturRios.Heimdall.Command.Output;
 4using ArturRios.Heimdall.Command.Services;
 5using ArturRios.Heimdall.Domain.Entities;
 6using ArturRios.Heimdall.Domain.Persistence;
 7using ArturRios.Heimdall.Shared.Messages;
 8using ArturRios.Mediator.Command.Interfaces;
 9using ArturRios.Output;
 10using FluentValidation;
 11using Microsoft.EntityFrameworkCore;
 12using Microsoft.Extensions.Logging;
 13
 14namespace ArturRios.Heimdall.Command.Handlers;
 15
 16/// <summary>
 17///     Handles <see cref="VerifyEmailCommand" /> (UC-14): finds the token UC-06 issued at person
 18///     creation, refuses it if it is unknown, if its account may no longer be acted on, or if it is
 19///     expired or spent (AF-14c/AF-14d/AF-14a/AF-14b), then marks the person's address as verified
 20///     and consumes the token (FR-EV-03). A token that matches none of those may instead belong to a
 21///     UC-47 pending registration, which <see cref="IPendingRegistrationConfirmer" /> turns into the
 22///     verified <c>User</c> it described when the command also carries the password chosen at sign-up
 23///     (a missing or wrong one is refused without consuming the registration); only when it matches
 24///     neither is it AF-14c. The password is read for pending registrations only and ignored for an
 25///     ordinary verification token.
 26/// </summary>
 27/// <remarks>
 28///     <para>
 29///         The mirror of <see cref="ResetPasswordCommandHandler" />, and it takes the same positions
 30///         for the same reasons. The token is the whole of the caller's claim — no email, no scope —
 31///         so the four rejections are checked in the order UC-14's main flow states: exists, its person
 32///         may still be acted on (answered as "exists" fails), not expired, not used. The token's own
 33///         three are named separately, unlike UC-11's uniform 401, because there is no account to
 34///         enumerate here: the token is a 48-character random string, and a caller either holds one
 35///         that was mailed to them or holds nothing.
 36///     </para>
 37///     <para>
 38///         An address that is already verified is verified again, and the token spent. UC-14 defines
 39///         no alternative flow for it — AF-15a rejects a *request* for another verification email, a
 40///         different thing — so refusing here would mean inventing one.
 41///     </para>
 42///     <para>
 43///         <b>The account is checked when the link is followed</b> (AF-14d, FR-EV-08), by the rule
 44///         UC-12 and UC-13 use (<see cref="PasswordRecoveryEligibility" />): a person restricted,
 45///         deleted or anonymised, or who lost the scope they sign in through, since the link was
 46///         mailed is answered exactly as an unknown token is (AF-14c), and nothing is written — a
 47///         restricted record is preserved as NFR-24 requires. The flag is set by one conditional
 48///         statement (<see cref="IAtomicWrites.TryMarkEmailVerifiedAsync" />) that repeats those
 49///         checks and requires the address the person was read with, so a change that lands in
 50///         between wins, and a save of the stale copy cannot put an old address back.
 51///     </para>
 52///     <para>
 53///         <b>The token is spent only with the write it unlocks</b> (#209), as in
 54///         <see cref="ResetPasswordCommandHandler" />: spending it and setting the flag share one
 55///         transaction, so a failed write leaves the link usable, while a write refused because the
 56///         person changed leaves the token spent. Retiring the person's other links runs once that has
 57///         committed, and a failure there is logged, not reported.
 58///     </para>
 59/// </remarks>
 15060public class VerifyEmailCommandHandler(
 15061    IValidator<VerifyEmailCommand> validator,
 15062    IAsyncReadOnlyRepository<EmailVerificationToken, long> tokenReader,
 15063    IAsyncRepository<EmailVerificationToken, long> tokenWriter,
 15064    IAtomicWrites atomicWrites,
 15065    IPendingRegistrationConfirmer pendingRegistrations,
 15066    IWriteTransactions transactions,
 15067    ILogger<VerifyEmailCommandHandler> logger)
 68    : ICommandHandlerAsync<VerifyEmailCommand, VerifyEmailCommandOutput>
 69{
 70    public async Task<DataOutput<VerifyEmailCommandOutput?>> HandleAsync(VerifyEmailCommand command, CancellationToken c
 71    {
 15072        var output = DataOutput<VerifyEmailCommandOutput?>.New;
 73
 74        // Validate input shape (NFR-10). UC-14 names no alternative flow for it; an absent token
 75        // would answer 400 as AF-14c anyway.
 15076        var validation = await validator.ValidateAsync(command);
 77
 15078        if (!validation.IsValid)
 79        {
 680            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 81        }
 82
 14783        var now = DateTime.UtcNow;
 84
 85        // UC-14 step 2. Matched by hash rather than by value, for the reason given in
 86        // ResetPasswordCommandHandler and in SingleUseTokenHash: the token is not stored.
 14787        var presented = SingleUseTokenHash.Of(command.Token);
 88
 89        // Both scope navigations are loaded: the eligibility rule reads them.
 14790        var token = await tokenReader.Query()
 14791            .Include(x => x.Person)
 14792            .ThenInclude(person => person.ScopeMembership!)
 14793            .ThenInclude(membership => membership.Scope)
 14794            .Include(x => x.Person)
 14795            .ThenInclude(person => person.ScopeOwnerships)
 14796            .ThenInclude(ownership => ownership.Scope)
 14797            .FirstOrDefaultAsync(x => x.TokenHash == presented);
 98
 99        // AF-14c — unless the token is a UC-47 pending registration's, in which case following the link
 100        // is what creates the account, already verified.
 147101        if (token is null)
 102        {
 101103            var confirmation = await pendingRegistrations.ConfirmAsync(presented, command.Password, now);
 104
 101105            return confirmation.Outcome switch
 101106            {
 4107                PendingRegistrationOutcome.Confirmed => output
 4108                    .WithData(new VerifyEmailCommandOutput())
 4109                    .WithMessage(AuthMessages.EmailVerifiedSuccessfully),
 2110                PendingRegistrationOutcome.Expired => output.WithError(AuthMessages.TokenExpired),
 1111                PendingRegistrationOutcome.AlreadyClaimed => output.WithError(AuthMessages.TokenAlreadyUsed),
 2112                PendingRegistrationOutcome.PasswordRequired => output.WithError(AuthMessages.ConfirmationPasswordRequire
 9113                PendingRegistrationOutcome.PasswordIncorrect => output.WithError(AuthMessages.ConfirmationPasswordIncorr
 1114                PendingRegistrationOutcome.Failed => output.WithErrors(confirmation.Errors),
 82115                _ => output.WithError(AuthMessages.TokenInvalid)
 101116            };
 117        }
 118
 119        // AF-14d (FR-EV-08): the account must still be one that may be acted on, answered as AF-14c
 120        // whatever the reason, and before the token's own state.
 46121        if (PasswordRecoveryEligibility.Of(token.Person) != RecoveryEligibility.Eligible)
 122        {
 8123            return output.WithError(AuthMessages.TokenInvalid);
 124        }
 125
 126        // AF-14a.
 38127        if (token.ExpiresAt <= now)
 128        {
 3129            return output.WithError(AuthMessages.TokenExpired);
 130        }
 131
 132        // AF-14b.
 35133        if (token.Used)
 134        {
 5135            return output.WithError(AuthMessages.TokenAlreadyUsed);
 136        }
 137
 138        // The token and the flag it unlocks land together or not at all (#209).
 30139        await using (var transaction = await transactions.BeginAsync())
 140        {
 141            // UC-14 step 4, for the presented token, brought forward and made atomic: of two requests
 142            // carrying the same token, only the one that spends it verifies the address (AF-14b for
 143            // the other).
 30144            if (!await atomicWrites.TryConsumeEmailVerificationTokenAsync(token, now))
 145            {
 7146                return output.WithError(AuthMessages.TokenAlreadyUsed);
 147            }
 148
 149            // UC-14 step 3 (FR-EV-03), written only while the person is still as found above
 150            // (FR-EV-08, FR-PE-17). Otherwise the link is answered as AF-14d would now answer it.
 23151            var written = await atomicWrites.TryMarkEmailVerifiedAsync(token.Person, now);
 152
 153            // Committed either way: a refused write is an answer, and the token stays spent. Only a
 154            // write that fails leaves the transaction to roll back.
 22155            await transaction.CommitAsync();
 156
 22157            if (!written)
 158            {
 3159                return output.WithError(AuthMessages.TokenInvalid);
 160            }
 161        }
 162
 163        // UC-14 step 4, for every other token the person still holds.
 19164        await RetireOtherTokensAsync(token, now);
 165
 166        // UC-14 step 5.
 19167        return output
 19168            .WithData(new VerifyEmailCommandOutput())
 19169            .WithMessage(AuthMessages.EmailVerifiedSuccessfully);
 149170    }
 171
 172    /// <summary>
 173    ///     UC-14 step 4, applied to every verification token the person still holds rather than only
 174    ///     the one presented. UC-06 issues one at creation and UC-15 issues more on request, so a
 175    ///     person can hold several live links; once one of them has verified the address, the rest
 176    ///     verify an address that is already verified and are worth nothing but the room they take up
 177    ///     in a mailbox.
 178    /// </summary>
 179    /// <remarks>
 180    ///     This is not UC-15 step 3, which retires outstanding tokens before issuing a new one. That
 181    ///     remains UC-15's own work.
 182    ///     <para>
 183    ///         Already-expired tokens are left alone. They are refused by AF-14a either way, and
 184    ///         rewriting them would only make a dead token report a different reason for being dead.
 185    ///     </para>
 186    ///     <para>
 187    ///         Best effort (#209): the address is already verified, so a failure here is logged and not
 188    ///         reported. It runs in a transaction of its own so that a failure retires none of them and
 189    ///         leaves nothing pending for the next save, the audit entry's.
 190    ///     </para>
 191    /// </remarks>
 192    private async Task RetireOtherTokensAsync(EmailVerificationToken token, DateTime now)
 193    {
 194        try
 195        {
 19196            await using var transaction = await transactions.BeginAsync();
 197
 19198            var live = await tokenReader.Query()
 19199                .Where(x => x.PersonId == token.PersonId && !x.Used && x.ExpiresAt > now)
 19200                .ToListAsync();
 201
 43202            foreach (var outstanding in live)
 203            {
 3204                outstanding.Used = true;
 205
 3206                var update = await tokenWriter.UpdateAsync(outstanding);
 207
 3208                if (!update.Success)
 209                {
 1210                    logger.LogWarning("The other verification links of a verified address could not be retired: {Errors}
 1211                        string.Join(" | ", update.Errors));
 212
 1213                    return;
 214                }
 215            }
 216
 18217            await transaction.CommitAsync();
 18218        }
 0219        catch (Exception exception)
 220        {
 0221            logger.LogWarning(exception, "The other verification links of a verified address could not be retired");
 0222        }
 19223    }
 224}