< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.SendPersonPasswordResetCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/SendPersonPasswordResetCommandHandler.cs
Line coverage
100%
Covered lines: 31
Uncovered lines: 0
Coverable lines: 31
Total lines: 93
Line coverage: 100%
Branch coverage
100%
Covered branches: 10
Total branches: 10
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%1010100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/SendPersonPasswordResetCommandHandler.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="SendPersonPasswordResetCommand" /> (UC-52, FR-PR-05/06): an administrator has
 16///     a password-reset link mailed to a person's own address.
 17/// </summary>
 18/// <remarks>
 19///     <para>
 20///         Eligibility is UC-12's, through <see cref="PasswordRecoveryEligibility" />, so the anonymous
 21///         recovery and this request cannot disagree about who may recover. Refusals, in order: not
 22///         found (AF-52a); the caller may not act on this person (AF-52b); restricted, then deleted
 23///         (AF-52c); the account could not sign in (AF-52d); the cooldown is running (AF-52e).
 24///     </para>
 25///     <para>
 26///         The send is made here, not through UC-12's anonymous queue: that queue exists so an
 27///         anonymous caller cannot tell a registered address from an unknown one by timing, and an
 28///         authenticated administrator is owed a real answer instead. Earlier live reset links are left
 29///         as <see cref="IPasswordResetService.IssueAndSendAsync" /> leaves them for UC-12.
 30///     </para>
 31/// </remarks>
 2432public class SendPersonPasswordResetCommandHandler(
 2433    IAsyncReadOnlyRepository<Person, long> personReader,
 2434    IAsyncReadOnlyRepository<PasswordResetToken, long> tokenReader,
 2435    IScopeOwnershipChecker scopeOwnership,
 2436    IPasswordResetService passwordReset,
 2437    PasswordResetOptions resetOptions,
 2438    AdminEmailOptions adminEmailOptions)
 39    : ICommandHandlerAsync<SendPersonPasswordResetCommand, SendPersonPasswordResetCommandOutput>
 40{
 41    public async Task<DataOutput<SendPersonPasswordResetCommandOutput?>> HandleAsync(
 42        SendPersonPasswordResetCommand command, CancellationToken cancellationToken = default)
 43    {
 2444        var output = DataOutput<SendPersonPasswordResetCommandOutput?>.New;
 45
 46        // AF-52a. Both scope navigations are loaded: the eligibility rule reads them.
 2447        var person = await personReader.Query()
 2448            .Include(x => x.ScopeMembership)
 2449            .ThenInclude(membership => membership!.Scope)
 2450            .Include(x => x.ScopeOwnerships)
 2451            .ThenInclude(ownership => ownership.Scope)
 2452            .FirstOrDefaultAsync(x => x.PublicId == command.Id, cancellationToken);
 53
 2454        if (person is null)
 55        {
 256            return output.WithError(PersonMessages.PersonNotFound);
 57        }
 58
 59        // AF-52b.
 2260        if (!await PersonEmailAuthorization.ActorMayEmailAsync(
 2261                scopeOwnership, command.ActingRole, command.ActingPersonId, person))
 62        {
 363            return output.WithError(PersonMessages.NotAuthorizedToEmailPerson);
 64        }
 65
 66        // AF-52c and AF-52d: UC-12's rule, reported rather than silently applied.
 1967        switch (PasswordRecoveryEligibility.Of(person))
 68        {
 69            case RecoveryEligibility.Restricted:
 270                return output.WithError(PersonMessages.PersonProcessingRestricted);
 71            case RecoveryEligibility.Deleted:
 272                return output.WithError(PersonMessages.PersonIsDeleted);
 73            case RecoveryEligibility.CannotSignIn:
 374                return output.WithError(PersonMessages.PersonCannotSignIn);
 75        }
 76
 77        // AF-52e. Any reset token issued for the person inside the window — UC-12's included, used
 78        // or not — means one was sent recently.
 79        // Best-effort: concurrent requests for the same person can both pass this check (no lock).
 80        // Accepted, because only an authenticated administrator can trigger it.
 1281        if (await adminEmailOptions.IsRunningAsync(
 1282                tokenReader, person.Id, resetOptions.TokenLifetime, DateTime.UtcNow, cancellationToken))
 83        {
 384            return output.WithError(PersonMessages.AccountEmailSentRecently);
 85        }
 86
 987        await passwordReset.IssueAndSendAsync(person);
 88
 989        return output
 990            .WithData(new SendPersonPasswordResetCommandOutput { Id = person.PublicId })
 991            .WithMessage(PersonMessages.PasswordResetSentToPerson);
 2492    }
 93}