< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Services.RegistrationProcessor
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Services/RegistrationProcessor.cs
Line coverage
100%
Covered lines: 71
Uncovered lines: 0
Coverable lines: 71
Total lines: 194
Line coverage: 100%
Branch coverage
100%
Covered branches: 24
Total branches: 24
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%2222100%
AdmitNoticeAsync()100%22100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/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///         The scope is bounded too (#206): at most <see cref="RegistrationOptions.MaxPendingPerScope" /> live
 42///         registrations, whatever their addresses, since enough source addresses outpace both the per-IP
 43///         limit and the purge. A request at the cap creates and mails nothing, like one beyond FR-SR-13, and
 44///         is logged at Warning with <see cref="CapReachedMarker" /> and the scope's id, because a scope
 45///         that reaches it is being flooded, or has outgrown the cap, and either needs an operator. It is
 46///         read and then written, as the address count is, so concurrent instances can overshoot it by one
 47///         each.
 48///     </para>
 49///     <para>
 50///         Every failure here is logged with a fixed message and no address (NFR-22), and none is thrown:
 51///         the caller already has the uniform answer.
 52///     </para>
 53/// </remarks>
 5254public class RegistrationProcessor(
 5255    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 5256    IAsyncReadOnlyRepository<Person, long> personReader,
 5257    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 5258    IAsyncReadOnlyRepository<PendingRegistration, long> pendingReader,
 5259    IAsyncRepository<PendingRegistration, long> pendingWriter,
 5260    IRegistrationLinkSender linkSender,
 5261    IRegistrationAttemptSender attemptSender,
 5262    IAttemptNoticeBudgets noticeBudgets,
 5263    EmailVerificationOptions options,
 5264    RegistrationOptions registration,
 5265    ILogger<RegistrationProcessor> logger)
 66{
 67    /// <summary>
 68    ///     The marker of the Warning logged when a scope's cap on live pending registrations turns a request
 69    ///     away. Stable and greppable: an alert should match on it rather than on the prose.
 70    /// </summary>
 71    public const string CapReachedMarker = "REGISTRATION_CAP_REACHED";
 72
 73    public async Task ProcessAsync(RegistrationRequest request)
 74    {
 6175        var scope = await scopeReader.Query().FirstOrDefaultAsync(x =>
 6176            x.PublicId == request.ScopeId && !x.IsDeleted && x.SelfRegistrationEnabled);
 77
 6178        if (scope is null)
 79        {
 280            return;
 81        }
 82
 5983        var email = request.Email.ToLower();
 84
 5985        var holder = await personReader.Query().FirstOrDefaultAsync(person =>
 5986            !person.IsDeleted && person.Email.ToLower() == email &&
 5987            person.ScopeMembership != null && person.ScopeMembership.ScopeId == scope.Id);
 88
 5989        if (holder is not null)
 90        {
 91            // NFR-24: a restricted identity is not mailed, not even to say someone tried.
 1692            if (holder.ProcessingRestrictedAt is null && await AdmitNoticeAsync(holder.Email))
 93            {
 1194                await attemptSender.SendAsync(holder.Email, googleAccount: false);
 95            }
 96
 1697            return;
 98        }
 99
 43100        var googleHolder = await googleUserReader.Query().FirstOrDefaultAsync(googleUser =>
 43101            googleUser.ScopeId == scope.Id && googleUser.Email.ToLower() == email);
 102
 43103        if (googleHolder is not null)
 104        {
 105            // The address stays blocked either way (FR-GO-07); a deleted or restricted Google holder
 106            // is not told, since it could not act on it — or, if restricted, must not be processed.
 6107            if (!googleHolder.IsDeleted && googleHolder.ProcessingRestrictedAt is null &&
 6108                await AdmitNoticeAsync(googleHolder.Email))
 109            {
 4110                await attemptSender.SendAsync(googleHolder.Email, googleAccount: true);
 111            }
 112
 6113            return;
 114        }
 115
 37116        var token = SingleUseToken.New();
 37117        var now = DateTime.UtcNow;
 118
 119        // FR-SR-13: the address already has as many live registrations in the scope as it may. Those
 120        // links still work; this request adds no fourth mail.
 37121        var live = await pendingReader.Query().CountAsync(pending =>
 37122            pending.ScopeId == scope.Id && pending.Email.ToLower() == email && pending.ExpiresAt > now);
 123
 37124        if (live >= PendingRegistration.MaxLivePerAddress)
 125        {
 126            // No address in the log (NFR-22).
 2127            logger.LogInformation(
 2128                "A registration was not stored: its address already has {Live} live in the scope", live);
 129
 2130            return;
 131        }
 132
 133        // #206: the scope already holds as many live registrations as it may, whatever their addresses.
 35134        var liveInScope = await pendingReader.Query().CountAsync(pending =>
 35135            pending.ScopeId == scope.Id && pending.ExpiresAt > now);
 136
 35137        if (liveInScope >= registration.MaxPendingPerScope)
 138        {
 139            // The scope's id, never the address (NFR-22).
 2140            logger.LogWarning(
 2141                "{Marker} A registration was not stored: scope {ScopeId} already has {Live} live pending " +
 2142                "registrations, its cap", CapReachedMarker, scope.PublicId, liveInScope);
 143
 2144            return;
 145        }
 146
 33147        var creation = await pendingWriter.CreateAsync(new PendingRegistration
 33148        {
 33149            ScopeId = scope.Id,
 33150            Name = request.Name,
 33151            Email = request.Email,
 33152            PasswordHash = request.PasswordHash,
 33153            Salt = request.Salt,
 33154            TokenHash = SingleUseTokenHash.Of(token),
 33155            ExpiresAt = now.Add(options.TokenLifetime),
 33156            CreatedAt = now
 33157        });
 158
 33159        if (!creation.Success)
 160        {
 161            // No address in the log (NFR-22). The caller has their 202; this line is the only trace.
 1162            logger.LogError(
 1163                "A pending registration could not be stored: {Errors}", string.Join(" | ", creation.Errors));
 164
 1165            return;
 166        }
 167
 168        // The link follows UC-14's route, which recognises the token as a pending one; the mail asks for
 169        // the sign-up password too.
 32170        await linkSender.SendAsync(request.Email, token, scope.EmailVerificationUrl);
 60171    }
 172
 173    /// <summary>
 174    ///     FR-SR-14: charges one attempt notice to <paramref name="address" />, and says whether it may
 175    ///     be sent. Counted by the address's digest, so every scope the address holds an account in
 176    ///     shares the one budget, as the inbox does.
 177    /// </summary>
 178    private async Task<bool> AdmitNoticeAsync(string address)
 179    {
 19180        var now = DateTime.UtcNow;
 181
 19182        var admitted = await noticeBudgets.TryChargeAsync(
 19183            AttemptNoticeBudget.AddressHashOf(address), AttemptNoticeBudget.MaxNotices, now,
 19184            now + AttemptNoticeBudget.Window);
 185
 19186        if (!admitted)
 187        {
 188            // No address in the log (NFR-22).
 4189            logger.LogInformation("A registration-attempt notice was withheld: the address has had its daily notices");
 190        }
 191
 19192        return admitted;
 19193    }
 194}