< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.VerifyEmailCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/VerifyEmailCommandHandler.cs
Line coverage
96%
Covered lines: 59
Uncovered lines: 2
Coverable lines: 61
Total lines: 193
Line coverage: 96.7%
Branch coverage
92%
Covered branches: 26
Total branches: 28
Branch coverage: 92.8%
Method coverage
100%
Covered methods: 3
Fully covered methods: 1
Total methods: 3
Method coverage: 100%
Full method coverage: 33.3%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()91.66%242495.45%
ConsumeTokensAsync()75%4490%

File(s)

/home/runner/work/heimdall-api/heimdall-api/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;
 12
 13namespace ArturRios.Heimdall.Command.Handlers;
 14
 15/// <summary>
 16///     Handles <see cref="VerifyEmailCommand" /> (UC-14): finds the token UC-06 issued at person
 17///     creation, refuses it if it is unknown, if its account may no longer be acted on, or if it is
 18///     expired or spent (AF-14c/AF-14d/AF-14a/AF-14b), then marks the person's address as verified
 19///     and consumes the token (FR-EV-03). A token that matches none of those may instead belong to a
 20///     UC-47 pending registration, which <see cref="IPendingRegistrationConfirmer" /> turns into the
 21///     verified <c>User</c> it described when the command also carries the password chosen at sign-up
 22///     (a missing or wrong one is refused without consuming the registration); only when it matches
 23///     neither is it AF-14c. The password is read for pending registrations only and ignored for an
 24///     ordinary verification token.
 25/// </summary>
 26/// <remarks>
 27///     <para>
 28///         The mirror of <see cref="ResetPasswordCommandHandler" />, and it takes the same positions
 29///         for the same reasons. The token is the whole of the caller's claim — no email, no scope —
 30///         so the four rejections are checked in the order UC-14's main flow states: exists, its person
 31///         may still be acted on (answered as "exists" fails), not expired, not used. The token's own
 32///         three are named separately, unlike UC-11's uniform 401, because there is no account to
 33///         enumerate here: the token is a 48-character random string, and a caller either holds one
 34///         that was mailed to them or holds nothing.
 35///     </para>
 36///     <para>
 37///         An address that is already verified is verified again, and the token spent. UC-14 defines
 38///         no alternative flow for it — AF-15a rejects a *request* for another verification email, a
 39///         different thing — so refusing here would mean inventing one.
 40///     </para>
 41///     <para>
 42///         <b>The account is checked when the link is followed</b> (AF-14d, FR-EV-08), by the rule
 43///         UC-12 and UC-13 use (<see cref="PasswordRecoveryEligibility" />): a person restricted,
 44///         deleted or anonymised, or who lost the scope they sign in through, since the link was
 45///         mailed is answered exactly as an unknown token is (AF-14c), and nothing is written — a
 46///         restricted record is preserved as NFR-24 requires. The flag is set by one conditional
 47///         statement (<see cref="IAtomicWrites.TryMarkEmailVerifiedAsync" />) that repeats those
 48///         checks and requires the address the person was read with, so a change that lands in
 49///         between wins, and a save of the stale copy cannot put an old address back.
 50///     </para>
 51/// </remarks>
 14552public class VerifyEmailCommandHandler(
 14553    IValidator<VerifyEmailCommand> validator,
 14554    IAsyncReadOnlyRepository<EmailVerificationToken, long> tokenReader,
 14555    IAsyncRepository<EmailVerificationToken, long> tokenWriter,
 14556    IAtomicWrites atomicWrites,
 14557    IPendingRegistrationConfirmer pendingRegistrations)
 58    : ICommandHandlerAsync<VerifyEmailCommand, VerifyEmailCommandOutput>
 59{
 60    public async Task<DataOutput<VerifyEmailCommandOutput?>> HandleAsync(VerifyEmailCommand command, CancellationToken c
 61    {
 14562        var output = DataOutput<VerifyEmailCommandOutput?>.New;
 63
 64        // Validate input shape (NFR-10). UC-14 names no alternative flow for it; an absent token
 65        // would answer 400 as AF-14c anyway.
 14566        var validation = await validator.ValidateAsync(command);
 67
 14568        if (!validation.IsValid)
 69        {
 670            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 71        }
 72
 14273        var now = DateTime.UtcNow;
 74
 75        // UC-14 step 2. Matched by hash rather than by value, for the reason given in
 76        // ResetPasswordCommandHandler and in SingleUseTokenHash: the token is not stored.
 14277        var presented = SingleUseTokenHash.Of(command.Token);
 78
 79        // Both scope navigations are loaded: the eligibility rule reads them.
 14280        var token = await tokenReader.Query()
 14281            .Include(x => x.Person)
 14282            .ThenInclude(person => person.ScopeMembership!)
 14283            .ThenInclude(membership => membership.Scope)
 14284            .Include(x => x.Person)
 14285            .ThenInclude(person => person.ScopeOwnerships)
 14286            .ThenInclude(ownership => ownership.Scope)
 14287            .FirstOrDefaultAsync(x => x.TokenHash == presented);
 88
 89        // AF-14c — unless the token is a UC-47 pending registration's, in which case following the link
 90        // is what creates the account, already verified.
 14291        if (token is null)
 92        {
 10193            var confirmation = await pendingRegistrations.ConfirmAsync(presented, command.Password, now);
 94
 10195            return confirmation.Outcome switch
 10196            {
 497                PendingRegistrationOutcome.Confirmed => output
 498                    .WithData(new VerifyEmailCommandOutput())
 499                    .WithMessage(AuthMessages.EmailVerifiedSuccessfully),
 2100                PendingRegistrationOutcome.Expired => output.WithError(AuthMessages.TokenExpired),
 1101                PendingRegistrationOutcome.AlreadyClaimed => output.WithError(AuthMessages.TokenAlreadyUsed),
 2102                PendingRegistrationOutcome.PasswordRequired => output.WithError(AuthMessages.ConfirmationPasswordRequire
 9103                PendingRegistrationOutcome.PasswordIncorrect => output.WithError(AuthMessages.ConfirmationPasswordIncorr
 1104                PendingRegistrationOutcome.Failed => output.WithErrors(confirmation.Errors),
 82105                _ => output.WithError(AuthMessages.TokenInvalid)
 101106            };
 107        }
 108
 109        // AF-14d (FR-EV-08): the account must still be one that may be acted on, answered as AF-14c
 110        // whatever the reason, and before the token's own state.
 41111        if (PasswordRecoveryEligibility.Of(token.Person) != RecoveryEligibility.Eligible)
 112        {
 8113            return output.WithError(AuthMessages.TokenInvalid);
 114        }
 115
 116        // AF-14a.
 33117        if (token.ExpiresAt <= now)
 118        {
 3119            return output.WithError(AuthMessages.TokenExpired);
 120        }
 121
 122        // AF-14b.
 30123        if (token.Used)
 124        {
 5125            return output.WithError(AuthMessages.TokenAlreadyUsed);
 126        }
 127
 128        // UC-14 step 4, for the presented token, brought forward and made atomic: of two requests
 129        // carrying the same token, only the one that spends it verifies the address (AF-14b for the
 130        // other).
 25131        if (!await atomicWrites.TryConsumeEmailVerificationTokenAsync(token, now))
 132        {
 7133            return output.WithError(AuthMessages.TokenAlreadyUsed);
 134        }
 135
 136        // UC-14 step 3 (FR-EV-03), written only while the person is still as found above
 137        // (FR-EV-08, FR-PE-17). Otherwise the link is answered as AF-14d would now answer it; the
 138        // token stays spent.
 18139        if (!await atomicWrites.TryMarkEmailVerifiedAsync(token.Person, now))
 140        {
 1141            return output.WithError(AuthMessages.TokenInvalid);
 142        }
 143
 144        // UC-14 step 4, for every other token the person still holds.
 17145        var consumption = await ConsumeTokensAsync(token, now);
 146
 17147        if (consumption is not null)
 148        {
 0149            return output.WithErrors(consumption);
 150        }
 151
 152        // UC-14 step 5.
 17153        return output
 17154            .WithData(new VerifyEmailCommandOutput())
 17155            .WithMessage(AuthMessages.EmailVerifiedSuccessfully);
 145156    }
 157
 158    /// <summary>
 159    ///     UC-14 step 4, applied to every verification token the person still holds rather than only
 160    ///     the one presented. UC-06 issues one at creation and UC-15 issues more on request, so a
 161    ///     person can hold several live links; once one of them has verified the address, the rest
 162    ///     verify an address that is already verified and are worth nothing but the room they take up
 163    ///     in a mailbox.
 164    /// </summary>
 165    /// <remarks>
 166    ///     This is not UC-15 step 3, which retires outstanding tokens before issuing a new one. That
 167    ///     remains UC-15's own work.
 168    ///     <para>
 169    ///         Already-expired tokens are left alone. They are refused by AF-14a either way, and
 170    ///         rewriting them would only make a dead token report a different reason for being dead.
 171    ///     </para>
 172    /// </remarks>
 173    private async Task<IEnumerable<string>?> ConsumeTokensAsync(EmailVerificationToken token, DateTime now)
 174    {
 17175        var live = await tokenReader.Query()
 17176            .Where(x => x.PersonId == token.PersonId && !x.Used && x.ExpiresAt > now)
 17177            .ToListAsync();
 178
 38179        foreach (var outstanding in live)
 180        {
 2181            outstanding.Used = true;
 182
 2183            var update = await tokenWriter.UpdateAsync(outstanding);
 184
 2185            if (!update.Success)
 186            {
 0187                return update.Errors;
 188            }
 189        }
 190
 17191        return null;
 17192    }
 193}