< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.ResendVerificationEmailCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/ResendVerificationEmailCommandHandler.cs
Line coverage
96%
Covered lines: 29
Uncovered lines: 1
Coverable lines: 30
Total lines: 116
Line coverage: 96.6%
Branch coverage
87%
Covered branches: 7
Total branches: 8
Branch coverage: 87.5%
Method coverage
100%
Covered methods: 2
Fully covered methods: 1
Total methods: 2
Method coverage: 100%
Full method coverage: 50%

Metrics

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

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/ResendVerificationEmailCommandHandler.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 Microsoft.EntityFrameworkCore;
 11
 12namespace ArturRios.Heimdall.Command.Handlers;
 13
 14/// <summary>
 15///     Handles <see cref="ResendVerificationEmailCommand" /> (UC-15): refuses an address that is
 16///     already verified (AF-15a) and a request the person's account-email budget refuses (AF-15b,
 17///     AF-15c, FR-PE-16), retires every verification link the caller still holds, and has a fresh one
 18///     issued and mailed (FR-EV-04).
 19/// </summary>
 20/// <remarks>
 21///     <para>
 22///         The counterpart of <see cref="VerifyEmailCommandHandler" />: that one spends a token, this
 23///         one replaces it. Issuing is not done here — <see cref="IEmailVerificationService" /> has
 24///         owned the token's length, alphabet, and lifetime since UC-06, and this handler decides only
 25///         whether and when to call it.
 26///     </para>
 27///     <para>
 28///         There is no input to validate and no authorization rule to enforce, because the request has
 29///         no body: the person comes from the bearer token, so the caller can only ever act on
 30///         themselves. That is also why the command needs no validator, as
 31///         <see cref="DeletePersonCommand" /> does not.
 32///     </para>
 33///     <para>
 34///         The send is reserved against the person's account-email budget (<see cref="AccountEmailBudget" />)
 35///         by one conditional write, before anything is retired or issued: without it, whoever holds a
 36///         token could loop this endpoint and mail the account's address — which may be one they set
 37///         themselves — once per request, and a check that read the token rows first let a whole burst
 38///         through. A link an administrator sent (UC-06, UC-51) is not charged to it.
 39///     </para>
 40///     <para>
 41///         A person who has since been logically deleted is still served. UC-15 defines no
 42///         alternative flow for it, so a refusal would be one this handler invented, and a verified
 43///         address grants nothing on its own — UC-11 refuses the login by AF-11c regardless. This
 44///         deliberately differs from UC-12, which withholds a reset link from a deleted person: UC-12
 45///         must answer identically either way for anti-enumeration reasons, so withholding costs it
 46///         nothing, while here it would have to be a named error no document defines.
 47///     </para>
 48/// </remarks>
 5249public class ResendVerificationEmailCommandHandler(
 5250    IAsyncReadOnlyRepository<Person, long> personReader,
 5251    IAsyncReadOnlyRepository<EmailVerificationToken, long> tokenReader,
 5252    IAsyncRepository<EmailVerificationToken, long> tokenWriter,
 5253    IEmailVerificationService emailVerification,
 5254    AdminEmailOptions emailOptions,
 5255    IAtomicWrites atomicWrites,
 5256    IRetryAfterAdvisor retryAfter)
 57    : ICommandHandlerAsync<ResendVerificationEmailCommand, ResendVerificationEmailCommandOutput>
 58{
 59    public async Task<DataOutput<ResendVerificationEmailCommandOutput?>> HandleAsync(
 60        ResendVerificationEmailCommand command, CancellationToken cancellationToken = default)
 61    {
 5262        var output = DataOutput<ResendVerificationEmailCommandOutput?>.New;
 63
 64        // UC-15 step 1. The lookup omits an !IsDeleted filter deliberately: a logically deleted person
 65        // is found and served, per the remarks above.
 5266        var person = await personReader.Query()
 5267            .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId);
 68
 69        // Authentication runs in ClaimsOnly mode — no database read per request — so a valid bearer
 70        // token outlives the person it names once they are hard deleted (UC-10). The token is fine;
 71        // there is simply no address left to send to.
 5272        if (person is null)
 73        {
 174            return output.WithError(AuthMessages.PersonNotFound);
 75        }
 76
 77        // UC-15 step 2 (AF-15a). Checked before step 3, so a refused request retires nothing.
 5178        if (person.EmailVerified)
 79        {
 480            return output.WithError(AuthMessages.EmailAlreadyVerified);
 81        }
 82
 83        // AF-15b, AF-15c (FR-PE-16): reserve the send, or refuse it. Also before step 3, so a refused
 84        // request retires nothing and the link the person already has keeps working.
 4785        var refusal = AccountEmailBudget.RefusalOf(
 4786            await AccountEmailBudget.TryReserveAsync(atomicWrites, person, emailOptions),
 4787                person, emailOptions, retryAfter);
 88
 4789        if (refusal is not null)
 90        {
 2591            return output.WithError(refusal);
 92        }
 93
 2294        var now = DateTime.UtcNow;
 95
 96        // UC-15 step 3: retire every live link, so the new one is the only one that works.
 2297        var retirement = await EmailVerificationTokenRetirement.RetireLiveAsync(
 2298            tokenReader, tokenWriter, person.Id, now);
 99
 22100        if (retirement is not null)
 101        {
 0102            return output.WithErrors(retirement);
 103        }
 104
 105        // UC-15 steps 4 and 5 (FR-EV-04). A delivery failure never reaches here: MailgunSender logs
 106        // and swallows it so UC-12's AF-12a cannot become an enumeration oracle. The token is
 107        // persisted before delivery is attempted, so a caller who receives nothing calls this endpoint
 108        // again — which is the whole point of the use case.
 22109        await emailVerification.IssueAndSendAsync(person);
 110
 111        // UC-15 step 6.
 22112        return output
 22113            .WithData(new ResendVerificationEmailCommandOutput())
 22114            .WithMessage(AuthMessages.VerificationEmailSent);
 52115    }
 116}