< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Services.PasswordRecoveryProcessor
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Services/PasswordRecoveryProcessor.cs
Line coverage
100%
Covered lines: 39
Uncovered lines: 0
Coverable lines: 39
Total lines: 107
Line coverage: 100%
Branch coverage
100%
Covered branches: 8
Total branches: 8
Branch coverage: 100%
Method coverage
100%
Covered methods: 3
Fully covered methods: 3
Total methods: 3
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
ProcessAsync()100%66100%
FindPersonAsync()100%22100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Services/PasswordRecoveryProcessor.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Domain.Entities;
 3using ArturRios.Heimdall.Domain.Enums;
 4using Microsoft.EntityFrameworkCore;
 5using Microsoft.Extensions.Logging;
 6
 7namespace ArturRios.Heimdall.Command.Services;
 8
 9/// <summary>
 10///     UC-12 steps 2 to 4, run in the background for a <see cref="PasswordRecoveryRequest" />: finds
 11///     the person by the lookup their role implies, and issues and mails them a reset token if they
 12///     could use one (FR-PR-01/02). AF-12a — nobody holds the address — is the absence of that work.
 13/// </summary>
 14/// <remarks>
 15///     <para>
 16///         <b>Why this is not on the request path.</b> The answer UC-12 gives is the same on every
 17///         path, but the time it took was not: a registered address waited for a token insert and a
 18///         Mailgun round trip, an unknown one returned at once. That difference is readable from
 19///         outside and answers the question the uniform answer exists to refuse. The handler now only
 20///         queues the request, so the request path does the same work whoever the address belongs
 21///         to, and the lookup, the insert and the send all happen here, after the caller has their
 22///         answer.
 23///     </para>
 24///     <para>
 25///         Run in its own DI scope per request (<c>PasswordRecoveryDispatcher</c>), so the database
 26///         context and the Mailgun client it uses belong to this work and not to an HTTP request that
 27///         has already completed.
 28///     </para>
 29/// </remarks>
 4730public class PasswordRecoveryProcessor(
 4731    IAsyncReadOnlyRepository<Person, long> personReader,
 4732    IAsyncReadOnlyRepository<PasswordResetToken, long> tokenReader,
 4733    IPasswordResetService passwordReset,
 4734    PasswordResetOptions resetOptions,
 4735    AdminEmailOptions emailOptions,
 4736    ILogger<PasswordRecoveryProcessor> logger)
 37{
 38    public async Task ProcessAsync(PasswordRecoveryRequest request)
 39    {
 40        // UC-12 step 2. As in UC-11, the lookup omits an !IsDeleted filter so a deleted person is
 41        // found and then declined below, rather than being indistinguishable from a missing row for
 42        // the wrong reason.
 4743        var person = await FindPersonAsync(request);
 44
 45        // UC-12 steps 3 and 4. Skipped entirely for AF-12a and for accounts that could not log in
 46        // anyway — sending a reset link to a deleted person's address would restore nothing.
 4747        if (person is null || PasswordRecoveryEligibility.Of(person) != RecoveryEligibility.Eligible)
 48        {
 1549            return;
 50        }
 51
 52        // FR-PR-07 (AF-12b): one reset link per person per cooldown, whoever asks — asked exactly as
 53        // UC-52 asks it, so a link either sent counts. The caller was answered long ago, so a request
 54        // inside it simply sends nothing, and the link already sent keeps working: the cooldown is
 55        // held to the link's lifetime, so it never outlasts the last link sent. Requests are worked one
 56        // at a time per instance, so only a second instance could overlap one. No daily cap: recovery
 57        // is anonymous, and a cap anyone could spend would deny the owner their own recovery.
 3258        var cooldown = emailOptions.CappedAt(resetOptions.TokenLifetime);
 59
 3260        if (await cooldown.IsRunningAsync(tokenReader, person.Id, resetOptions.TokenLifetime, DateTime.UtcNow))
 61        {
 62            // No address in the log (NFR-22).
 263            logger.LogInformation("A password reset was not sent: one went to the person within the cooldown");
 64
 265            return;
 66        }
 67
 3068        await passwordReset.IssueAndSendAsync(person);
 4669    }
 70
 71    /// <summary>
 72    ///     UC-12 step 2, the same role-driven lookup as UC-11: a <c>User</c> is sought within the
 73    ///     scope the request names, since their email is only unique there; a <c>ScopeAdmin</c> or
 74    ///     <c>SystemAdmin</c> system-wide among admins. Emails are compared case-insensitively
 75    ///     (LOWER() in SQL), matching how uniqueness is enforced when a person is created.
 76    /// </summary>
 77    private async Task<Person?> FindPersonAsync(PasswordRecoveryRequest request)
 78    {
 4779        var email = request.Email.ToLower();
 80
 4781        var query = personReader.Query()
 4782            .Include(person => person.ScopeMembership)
 4783            .ThenInclude(membership => membership!.Scope)
 4784            .Include(person => person.ScopeOwnerships)
 4785            .ThenInclude(ownership => ownership.Scope)
 4786            // Live persons first. Email uniqueness (FR-PE-09) holds among live persons only, so a
 4787            // logically deleted person awaiting anonymisation can share an address with the live
 4788            // person who replaced them. Without an order the database chose between the two, and
 4789            // choosing the deleted one refused the live person for as long as the other remained.
 4790            // A deleted person is still found when nobody live holds the address, which is what
 4791            // lets the deleted-person check reject them.
 4792            .OrderBy(person => person.IsDeleted);
 93
 4794        if (request.ScopeId is null)
 95        {
 4196            return await query.FirstOrDefaultAsync(person =>
 4197                person.Email.ToLower() == email &&
 4198                (person.RoleId == (long)Roles.SystemAdmin || person.RoleId == (long)Roles.ScopeAdmin));
 99        }
 100
 6101        return await query.FirstOrDefaultAsync(person =>
 6102            person.Email.ToLower() == email &&
 6103            person.RoleId == (long)Roles.User &&
 6104            person.ScopeMembership != null &&
 6105            person.ScopeMembership.Scope.PublicId == request.ScopeId);
 47106    }
 107}