< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Query.Handlers.ListScopeOwnersQueryHandler
Assembly: ArturRios.Heimdall.Query
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Query/Handlers/ListScopeOwnersQueryHandler.cs
Line coverage
98%
Covered lines: 50
Uncovered lines: 1
Coverable lines: 51
Total lines: 120
Line coverage: 98%
Branch coverage
92%
Covered branches: 13
Total branches: 14
Branch coverage: 92.8%
Method coverage
100%
Covered methods: 2
Fully covered methods: 1
Total methods: 2
Method coverage: 100%
Full method coverage: 50%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()92.85%1414100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Query/Handlers/ListScopeOwnersQueryHandler.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="ListScopeOwnersQuery" /> (UC-07, FR-PE-04): lists the <c>ScopeAdmin</c>
 16///     owners of a scope with pagination and optional name/email filters, excluding logically deleted
 17///     persons unless explicitly requested (FR-PE-08). A missing or logically deleted scope is AF-07a
 18///     (<c>ScopeNotFound</c>); an actor who does not own the scope is AF-07b (<c>NotScopeOwner</c>).
 19///     A System Admin bypasses the ownership check.
 20/// </summary>
 1921public class ListScopeOwnersQueryHandler(
 1922    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 1923    IAsyncReadOnlyRepository<Person, long> personReader,
 1924    IScopeOwnershipChecker scopeOwnership,
 1925    IValidator<ListScopeOwnersQuery> validator)
 26    : IPaginatedQueryHandlerAsync<ListScopeOwnersQuery, PersonOutput>
 27{
 28    public async Task<PaginatedOutput<PersonOutput>> HandleAsync(ListScopeOwnersQuery query, CancellationToken cancellat
 29    {
 1930        var output = PaginatedOutput<PersonOutput>.New;
 31
 32        // NFR-10: page number/size bounds and filter length, validated before any query runs.
 1933        var validation = await validator.ValidateAsync(query);
 34
 1935        if (!validation.IsValid)
 36        {
 037            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 38        }
 39
 40        // AF-07a: the target scope must exist and not be logically deleted.
 1941        var scope = await scopeReader.Query()
 1942            .FirstOrDefaultAsync(x => x.PublicId == query.ScopeId && !x.IsDeleted);
 43
 1944        if (scope is null)
 45        {
 346            return output.WithError(PersonMessages.ScopeNotFound);
 47        }
 48
 49        // AF-07b: a Scope Admin may only read a scope they own; a System Admin bypasses.
 1650        if (!await scopeOwnership.ActorMayManageScopeAsync(query.ActingRole, query.ActingPersonId, scope.Id))
 51        {
 252            return output.WithError(PersonMessages.NotScopeOwner);
 53        }
 54
 1455        var owners = personReader.Query()
 1456            .Where(x => x.ScopeOwnerships.Any(ownership => ownership.ScopeId == scope.Id));
 57
 58        // NFR-24: a restricted identity is withheld from tenant-facing listings, whatever
 59        // IncludeDeleted says — the same rule ListScopePersons and ListScopeGoogleUsers apply. An
 60        // owner is no less a data subject than a User.
 1461        owners = owners.Where(x => x.ProcessingRestrictedAt == null);
 62
 1463        if (!query.IncludeDeleted)
 64        {
 1265            owners = owners.Where(x => !x.IsDeleted);
 66        }
 67
 1468        if (!string.IsNullOrWhiteSpace(query.Name))
 69        {
 270            var name = query.Name.ToLower();
 271            owners = owners.Where(x => x.Name.ToLower().Contains(name));
 72        }
 73
 1474        if (!string.IsNullOrWhiteSpace(query.Email))
 75        {
 176            var email = query.Email.ToLower();
 177            owners = owners.Where(x => x.Email.ToLower().Contains(email));
 78        }
 79
 80        // FR-PE-19: an owner's other scopes are other tenants'. A Scope Admin sees only the
 81        // ownerships of scopes they own too — which always includes this one — and a System Admin
 82        // sees them all.
 1483        var visibleScopeIds = await OwnedScopeVisibility.ScopesVisibleToAsync(
 1484            personReader, query.ActingRole, query.ActingPersonId);
 1485        var seesEveryOwnership = visibleScopeIds is null;
 1486        var visible = visibleScopeIds ?? [];
 87
 1488        var projected = owners.Select(x => new PersonOutput
 1489        {
 1490            Id = x.PublicId,
 1491            Name = x.Name,
 1492            Email = x.Email,
 1493            Role = (int)x.RoleId,
 1494            EmailVerified = x.EmailVerified,
 1495            TwoFactorEnabled = x.TwoFactorAuth != null && x.TwoFactorAuth.IsActive,
 1496            IsDeleted = x.IsDeleted,
 1497            ScopeId = x.ScopeMembership != null ? x.ScopeMembership.Scope.PublicId : null,
 1498            OwnedScopeIds = x.ScopeOwnerships
 1499                .Where(ownership => seesEveryOwnership || visible.Contains(ownership.ScopeId))
 14100                .Select(ownership => ownership.Scope.PublicId)
 14101                .ToList(),
 14102            CreatedAt = x.CreatedAt,
 14103            UpdatedAt = x.UpdatedAt
 14104        });
 105
 106        // Ordered by name with the public identifier as a tiebreaker, then paginated over that
 107        // ordering rather than handing PaginateAsync a sort key of its own.
 108        //
 109        // Name is not unique, and PostgreSQL gives no ordering guarantee between rows whose sort key
 110        // ties — each page is a separate query, free to break the tie differently. Two people called
 111        // "Ana Silva" straddling a page boundary could therefore appear on both pages while somebody
 112        // else appeared on neither. The tiebreaker makes the total order deterministic, so paging
 113        // through a list sees every row exactly once.
 14114        var ordered = projected.OrderBy(x => x.Name).ThenBy(x => x.Id);
 115
 14116        var page = await ordered.PaginateAsync(query.PageNumber, query.PageSize, orderBy: null);
 117
 14118        return page.WithMessage(PersonMessages.PersonsRetrievedSuccessfully);
 19119    }
 120}