< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.SendPersonVerificationEmailCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/SendPersonVerificationEmailCommandHandler.cs
Line coverage
100%
Covered lines: 35
Uncovered lines: 0
Coverable lines: 35
Total lines: 109
Line coverage: 100%
Branch coverage
100%
Covered branches: 14
Total branches: 14
Branch coverage: 100%
Method coverage
100%
Covered methods: 2
Fully covered methods: 2
Total methods: 2
Method coverage: 100%
Full method coverage: 100%

Metrics

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

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/SendPersonVerificationEmailCommandHandler.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.Shared.Messages;
 7using ArturRios.Heimdall.Shared.Services;
 8using ArturRios.Mediator.Command.Interfaces;
 9using ArturRios.Output;
 10using Microsoft.EntityFrameworkCore;
 11
 12namespace ArturRios.Heimdall.Command.Handlers;
 13
 14/// <summary>
 15///     Handles <see cref="SendPersonVerificationEmailCommand" /> (UC-51, FR-EV-06/07): an administrator
 16///     has a fresh verification link mailed to a person's own address.
 17/// </summary>
 18/// <remarks>
 19///     <para>
 20///         Refusals, in order: not found (AF-51a); the caller may not act on this person (AF-51b);
 21///         restricted, then deleted (AF-51c); already verified (AF-51d); the cooldown is running
 22///         (AF-51e). Authorization comes before the state checks, as UC-09's does, so a Scope Admin
 23///         learns nothing about persons outside their scopes.
 24///     </para>
 25///     <para>
 26///         The send itself is UC-15's: every live link is retired, then
 27///         <see cref="IEmailVerificationService" /> issues and mails a new one, so the link uses the
 28///         scope's <c>EmailVerificationUrl</c> exactly as self-service does. The token never appears
 29///         in the output or in a log. A Mailgun failure is swallowed by the sender as everywhere else,
 30///         so success means the email was handed over, not delivered.
 31///     </para>
 32/// </remarks>
 2833public class SendPersonVerificationEmailCommandHandler(
 2834    IAsyncReadOnlyRepository<Person, long> personReader,
 2835    IAsyncReadOnlyRepository<EmailVerificationToken, long> tokenReader,
 2836    IAsyncRepository<EmailVerificationToken, long> tokenWriter,
 2837    IScopeOwnershipChecker scopeOwnership,
 2838    IEmailVerificationService emailVerification,
 2839    EmailVerificationOptions verificationOptions,
 2840    AdminEmailOptions adminEmailOptions)
 41    : ICommandHandlerAsync<SendPersonVerificationEmailCommand, SendPersonVerificationEmailCommandOutput>
 42{
 43    public async Task<DataOutput<SendPersonVerificationEmailCommandOutput?>> HandleAsync(
 44        SendPersonVerificationEmailCommand command, CancellationToken cancellationToken = default)
 45    {
 2846        var output = DataOutput<SendPersonVerificationEmailCommandOutput?>.New;
 47
 48        // AF-51a. No !IsDeleted filter: a logically deleted person is found and refused by AF-51c,
 49        // after authorization. A Google User's id misses — they are not persons.
 2850        var person = await personReader.Query()
 2851            .FirstOrDefaultAsync(x => x.PublicId == command.Id, cancellationToken);
 52
 2853        if (person is null)
 54        {
 355            return output.WithError(PersonMessages.PersonNotFound);
 56        }
 57
 58        // AF-51b.
 2559        if (!await PersonEmailAuthorization.ActorMayEmailAsync(
 2560                scopeOwnership, command.ActingRole, command.ActingPersonId, person))
 61        {
 462            return output.WithError(PersonMessages.NotAuthorizedToEmailPerson);
 63        }
 64
 65        // AF-51c. The restriction first, as UC-12/UC-52 check it (NFR-24).
 2166        if (person.ProcessingRestrictedAt is not null)
 67        {
 368            return output.WithError(PersonMessages.PersonProcessingRestricted);
 69        }
 70
 1871        if (person.IsDeleted)
 72        {
 373            return output.WithError(PersonMessages.PersonIsDeleted);
 74        }
 75
 76        // AF-51d.
 1577        if (person.EmailVerified)
 78        {
 279            return output.WithError(AuthMessages.EmailAlreadyVerified);
 80        }
 81
 82        // AF-51e. Any verification token issued for the person inside the window — used, superseded
 83        // or live, self-service or not — means one was sent recently.
 84        // Best-effort: concurrent requests for the same person can both pass this check (no lock).
 85        // Accepted, because only an authenticated administrator can trigger it.
 1386        var now = DateTime.UtcNow;
 87
 1388        if (await adminEmailOptions.IsRunningAsync(
 1389                tokenReader, person.Id, verificationOptions.TokenLifetime, now, cancellationToken))
 90        {
 391            return output.WithError(PersonMessages.AccountEmailSentRecently);
 92        }
 93
 94        // As UC-15 step 3: retire every live link, so the new one is the only one that works.
 1095        var retirement = await EmailVerificationTokenRetirement.RetireLiveAsync(
 1096            tokenReader, tokenWriter, person.Id, now);
 97
 1098        if (retirement is not null)
 99        {
 1100            return output.WithErrors(retirement);
 101        }
 102
 9103        await emailVerification.IssueAndSendAsync(person);
 104
 9105        return output
 9106            .WithData(new SendPersonVerificationEmailCommandOutput { Id = person.PublicId })
 9107            .WithMessage(PersonMessages.VerificationEmailSentToPerson);
 28108    }
 109}