< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Query.Handlers.ListPendingRegistrationsQueryHandler
Assembly: ArturRios.Heimdall.Query
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Query/Handlers/ListPendingRegistrationsQueryHandler.cs
Line coverage
100%
Covered lines: 35
Uncovered lines: 0
Coverable lines: 35
Total lines: 97
Line coverage: 100%
Branch coverage
100%
Covered branches: 6
Total branches: 6
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%66100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Query/Handlers/ListPendingRegistrationsQueryHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Domain.Entities;
 3using ArturRios.Heimdall.Query.Input;
 4using ArturRios.Heimdall.Query.Output;
 5using ArturRios.Heimdall.Shared.Messages;
 6using ArturRios.Heimdall.Shared.Services;
 7using ArturRios.Mediator.Query.Interfaces;
 8using ArturRios.Output;
 9using FluentValidation;
 10using Microsoft.EntityFrameworkCore;
 11
 12namespace ArturRios.Heimdall.Query.Handlers;
 13
 14/// <summary>
 15///     Handles <see cref="ListPendingRegistrationsQuery" /> (UC-49, FR-SR-11): a scope's
 16///     self-registrations awaiting confirmation, newest first, for a System Admin or an owner of the
 17///     scope.
 18/// </summary>
 19/// <remarks>
 20///     <para>
 21///         A missing or logically deleted scope is AF-49a (<c>ScopeNotFound</c>), as for the scope's
 22///         owners listing; a caller who neither is a System Admin nor owns it is AF-49b
 23///         (<c>NotScopeOwner</c>).
 24///     </para>
 25///     <para>
 26///         Whether the scope is still open to self-registration does not matter. Closing sign-up is
 27///         when an owner most wants to see, and clear, what was left behind.
 28///     </para>
 29///     <para>
 30///         FR-SR-06's anti-enumeration guarantee is about the anonymous <c>POST /api/auth/register</c>
 31///         and is untouched: this listing is authenticated and limited to the scope's owners, and a taken
 32///         address never produces a pending row.
 33///     </para>
 34/// </remarks>
 2035public class ListPendingRegistrationsQueryHandler(
 2036    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 2037    IAsyncReadOnlyRepository<PendingRegistration, long> pendingReader,
 2038    IScopeOwnershipChecker scopeOwnership,
 2039    IValidator<ListPendingRegistrationsQuery> validator)
 40    : IPaginatedQueryHandlerAsync<ListPendingRegistrationsQuery, PendingRegistrationOutput>
 41{
 42    public async Task<PaginatedOutput<PendingRegistrationOutput>> HandleAsync(
 43        ListPendingRegistrationsQuery query, CancellationToken cancellationToken = default)
 44    {
 2045        var output = PaginatedOutput<PendingRegistrationOutput>.New;
 46
 47        // NFR-10: page bounds, before any query runs.
 2048        var validation = await validator.ValidateAsync(query, cancellationToken);
 49
 2050        if (!validation.IsValid)
 51        {
 252            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 53        }
 54
 55        // AF-49a.
 1956        var scope = await scopeReader.Query()
 1957            .FirstOrDefaultAsync(x => x.PublicId == query.ScopeId && !x.IsDeleted, cancellationToken);
 58
 1959        if (scope is null)
 60        {
 461            return output.WithError(ScopeMessages.ScopeNotFound);
 62        }
 63
 64        // AF-49b: a System Admin bypasses; anyone else must own the scope.
 1565        if (!await scopeOwnership.ActorMayManageScopeAsync(query.ActingRole, query.ActingPersonId, scope.Id))
 66        {
 267            return output.WithError(ScopeMessages.NotScopeOwner);
 68        }
 69
 70        // One reading of the clock for every row, so "expired" means the same thing across a page.
 1371        var now = DateTime.UtcNow;
 72        const int budget = PendingRegistration.MaxConfirmationGuesses;
 73
 74        // Newest first with the internal id as tiebreaker, ordered on the entity because the id never
 75        // reaches the output, then projected and paginated over that ordering.
 1376        var projected = pendingReader.Query()
 1377            .Where(x => x.ScopeId == scope.Id)
 1378            .OrderByDescending(x => x.CreatedAt)
 1379            .ThenByDescending(x => x.Id)
 1380            .Select(x => new PendingRegistrationOutput
 1381            {
 1382                Id = x.PublicId,
 1383                Name = x.Name,
 1384                Email = x.Email,
 1385                CreatedAt = x.CreatedAt,
 1386                ExpiresAt = x.ExpiresAt,
 1387                // Parallel wrong guesses can push the count past the budget before the row is
 1388                // discarded (see PendingRegistrationConfirmer), so the remainder is floored at zero.
 1389                AttemptsLeft = x.FailedAttempts >= budget ? 0 : budget - x.FailedAttempts,
 1390                Expired = x.ExpiresAt <= now
 1391            });
 92
 1393        var page = await projected.PaginateAsync(query.PageNumber, query.PageSize, orderBy: null);
 94
 1395        return page.WithMessage(ScopeMessages.PendingRegistrationsRetrieved);
 2096    }
 97}