< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Services.RegistrationProcessor
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Services/RegistrationProcessor.cs
Line coverage
100%
Covered lines: 63
Uncovered lines: 0
Coverable lines: 63
Total lines: 164
Line coverage: 100%
Branch coverage
100%
Covered branches: 22
Total branches: 22
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%2020100%
AdmitNoticeAsync()100%22100%

File(s)

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

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Domain.Entities;
 3using ArturRios.Heimdall.Domain.Persistence;
 4using Microsoft.EntityFrameworkCore;
 5using Microsoft.Extensions.Logging;
 6
 7namespace ArturRios.Heimdall.Command.Services;
 8
 9/// <summary>
 10///     UC-47 steps 4 to 6, run in the background for a <see cref="RegistrationRequest" />: stores a
 11///     <see cref="PendingRegistration" /> and mails its confirmation link when the address is free in the
 12///     scope, or tells the address's holder of the attempt, at the address the holder registered with,
 13///     when it is not (AF-47b). No <c>Person</c> is created here: the verified <c>User</c> exists only
 14///     once the link is followed (UC-14).
 15/// </summary>
 16/// <remarks>
 17///     <para>
 18///         Off the request path for the reason <see cref="PasswordRecoveryProcessor" /> is: the lookup,
 19///         the insert and the email take a different time depending on the answer, and the answer is
 20///         what the uniform response exists to withhold.
 21///     </para>
 22///     <para>
 23///         The scope is re-read and re-checked because it may have been closed or deleted while the
 24///         request waited. The uniqueness rule is <see cref="Handlers.CreateUserCommandHandler" />'s:
 25///         live persons of the scope and every Google User of the scope, compared case-insensitively.
 26///         Pending registrations are not part of it: several may wait for one address, and the first
 27///         link followed wins (the confirmer re-checks the rule when it creates the <c>User</c>).
 28///     </para>
 29///     <para>
 30///         Each request can mail an address, and the caller never learns whether it did, so the mail
 31///         one address can be sent this way is bounded here rather than by the caller: at most
 32///         <see cref="PendingRegistration.MaxLivePerAddress" /> live registrations per scope and
 33///         address, each with its one confirmation mail (FR-SR-13), and at most
 34///         <see cref="AttemptNoticeBudget.MaxNotices" /> attempt notices per address a day, whichever
 35///         scope the attempt named (FR-SR-14). A request beyond either creates and mails nothing. The
 36///         notice budget is charged atomically; the registration count is read and then written, so
 37///         two instances processing requests for one address at the same moment can each add one —
 38///         the dispatcher takes one request at a time, so that takes a second instance.
 39///     </para>
 40///     <para>
 41///         Every failure here is logged with a fixed message and no address (NFR-22), and none is thrown:
 42///         the caller already has the uniform answer.
 43///     </para>
 44/// </remarks>
 4145public class RegistrationProcessor(
 4146    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 4147    IAsyncReadOnlyRepository<Person, long> personReader,
 4148    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 4149    IAsyncReadOnlyRepository<PendingRegistration, long> pendingReader,
 4150    IAsyncRepository<PendingRegistration, long> pendingWriter,
 4151    IRegistrationLinkSender linkSender,
 4152    IRegistrationAttemptSender attemptSender,
 4153    IAttemptNoticeBudgets noticeBudgets,
 4154    EmailVerificationOptions options,
 4155    ILogger<RegistrationProcessor> logger)
 56{
 57    public async Task ProcessAsync(RegistrationRequest request)
 58    {
 5059        var scope = await scopeReader.Query().FirstOrDefaultAsync(x =>
 5060            x.PublicId == request.ScopeId && !x.IsDeleted && x.SelfRegistrationEnabled);
 61
 5062        if (scope is null)
 63        {
 264            return;
 65        }
 66
 4867        var email = request.Email.ToLower();
 68
 4869        var holder = await personReader.Query().FirstOrDefaultAsync(person =>
 4870            !person.IsDeleted && person.Email.ToLower() == email &&
 4871            person.ScopeMembership != null && person.ScopeMembership.ScopeId == scope.Id);
 72
 4873        if (holder is not null)
 74        {
 75            // NFR-24: a restricted identity is not mailed, not even to say someone tried.
 1676            if (holder.ProcessingRestrictedAt is null && await AdmitNoticeAsync(holder.Email))
 77            {
 1178                await attemptSender.SendAsync(holder.Email, googleAccount: false);
 79            }
 80
 1681            return;
 82        }
 83
 3284        var googleHolder = await googleUserReader.Query().FirstOrDefaultAsync(googleUser =>
 3285            googleUser.ScopeId == scope.Id && googleUser.Email.ToLower() == email);
 86
 3287        if (googleHolder is not null)
 88        {
 89            // The address stays blocked either way (FR-GO-07); a deleted or restricted Google holder
 90            // is not told, since it could not act on it — or, if restricted, must not be processed.
 691            if (!googleHolder.IsDeleted && googleHolder.ProcessingRestrictedAt is null &&
 692                await AdmitNoticeAsync(googleHolder.Email))
 93            {
 494                await attemptSender.SendAsync(googleHolder.Email, googleAccount: true);
 95            }
 96
 697            return;
 98        }
 99
 26100        var token = SingleUseToken.New();
 26101        var now = DateTime.UtcNow;
 102
 103        // FR-SR-13: the address already has as many live registrations in the scope as it may. Those
 104        // links still work; this request adds no fourth mail.
 26105        var live = await pendingReader.Query().CountAsync(pending =>
 26106            pending.ScopeId == scope.Id && pending.Email.ToLower() == email && pending.ExpiresAt > now);
 107
 26108        if (live >= PendingRegistration.MaxLivePerAddress)
 109        {
 110            // No address in the log (NFR-22).
 2111            logger.LogInformation(
 2112                "A registration was not stored: its address already has {Live} live in the scope", live);
 113
 2114            return;
 115        }
 116
 24117        var creation = await pendingWriter.CreateAsync(new PendingRegistration
 24118        {
 24119            ScopeId = scope.Id,
 24120            Name = request.Name,
 24121            Email = request.Email,
 24122            PasswordHash = request.PasswordHash,
 24123            Salt = request.Salt,
 24124            TokenHash = SingleUseTokenHash.Of(token),
 24125            ExpiresAt = now.Add(options.TokenLifetime),
 24126            CreatedAt = now
 24127        });
 128
 24129        if (!creation.Success)
 130        {
 131            // No address in the log (NFR-22). The caller has their 202; this line is the only trace.
 1132            logger.LogError(
 1133                "A pending registration could not be stored: {Errors}", string.Join(" | ", creation.Errors));
 134
 1135            return;
 136        }
 137
 138        // The link follows UC-14's route, which recognises the token as a pending one; the mail asks for
 139        // the sign-up password too.
 23140        await linkSender.SendAsync(request.Email, token, scope.EmailVerificationUrl);
 50141    }
 142
 143    /// <summary>
 144    ///     FR-SR-14: charges one attempt notice to <paramref name="address" />, and says whether it may
 145    ///     be sent. Counted by the address's digest, so every scope the address holds an account in
 146    ///     shares the one budget, as the inbox does.
 147    /// </summary>
 148    private async Task<bool> AdmitNoticeAsync(string address)
 149    {
 19150        var now = DateTime.UtcNow;
 151
 19152        var admitted = await noticeBudgets.TryChargeAsync(
 19153            AttemptNoticeBudget.AddressHashOf(address), AttemptNoticeBudget.MaxNotices, now,
 19154            now + AttemptNoticeBudget.Window);
 155
 19156        if (!admitted)
 157        {
 158            // No address in the log (NFR-22).
 4159            logger.LogInformation("A registration-attempt notice was withheld: the address has had its daily notices");
 160        }
 161
 19162        return admitted;
 19163    }
 164}