< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Query.Handlers.ListScopePermissionsQueryHandler
Assembly: ArturRios.Heimdall.Query
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Query/Handlers/ListScopePermissionsQueryHandler.cs
Line coverage
97%
Covered lines: 35
Uncovered lines: 1
Coverable lines: 36
Total lines: 101
Line coverage: 97.2%
Branch coverage
90%
Covered branches: 9
Total branches: 10
Branch coverage: 90%
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()90%1010100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Query/Handlers/ListScopePermissionsQueryHandler.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="ListScopePermissionsQuery" /> (UC-32, FR-SP-05): lists a scope's
 16///     permissions with pagination and an optional name filter, excluding logically deleted
 17///     permissions unless explicitly requested (FR-SP-09). A System Admin sees every permission in
 18///     the scope; any other actor must own the scope. A missing or logically deleted scope reuses
 19///     AF-31a (<c>ScopeNotFound</c>); an actor who does not own the scope is AF-32e
 20///     (<c>NotScopeOwner</c>). A scope permission has no owner of its own, so unlike applications
 21///     there is no per-owner narrowing — owning the scope is the whole of the rule.
 22/// </summary>
 2123public class ListScopePermissionsQueryHandler(
 2124    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 2125    IAsyncReadOnlyRepository<ScopePermission, long> permissionReader,
 2126    IScopeOwnershipChecker scopeOwnership,
 2127    IValidator<ListScopePermissionsQuery> validator)
 28    : IPaginatedQueryHandlerAsync<ListScopePermissionsQuery, ScopePermissionOutput>
 29{
 30    public async Task<PaginatedOutput<ScopePermissionOutput>> HandleAsync(ListScopePermissionsQuery query, CancellationT
 31    {
 2132        var output = PaginatedOutput<ScopePermissionOutput>.New;
 33
 34        // NFR-10: page number/size bounds and filter length, validated before any query runs.
 2135        var validation = await validator.ValidateAsync(query);
 36
 2137        if (!validation.IsValid)
 38        {
 039            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 40        }
 41
 42        // AF-31a (reused): the target scope must exist and not be logically deleted.
 2143        var scope = await scopeReader.Query()
 2144            .FirstOrDefaultAsync(x => x.PublicId == query.ScopeId && !x.IsDeleted);
 45
 2146        if (scope is null)
 47        {
 448            return output.WithError(ScopePermissionMessages.ScopeNotFound);
 49        }
 50
 51        // AF-32e: a Scope Admin may only list a scope they own; a System Admin bypasses inside the
 52        // checker. The gate is not redundant with the empty-page outcome: without it, an actor
 53        // probing an unrelated scope would get an empty 200, indistinguishable from a scope that is
 54        // genuinely empty.
 1755        if (!await scopeOwnership.ActorMayManageScopeAsync(query.ActingRole, query.ActingPersonId, scope.Id))
 56        {
 357            return output.WithError(ScopePermissionMessages.NotScopeOwner);
 58        }
 59
 1460        var permissions = permissionReader.Query().Where(x => x.ScopeId == scope.Id);
 61
 62        // FR-SP-09: logically deleted permissions are excluded unless explicitly requested.
 1463        if (!query.IncludeDeleted)
 64        {
 1265            permissions = permissions.Where(x => !x.IsDeleted);
 66        }
 67
 68        // Compared case-insensitively (LOWER() in SQL), as every name filter in this codebase is.
 1469        if (!string.IsNullOrWhiteSpace(query.Name))
 70        {
 271            var name = query.Name.ToLower();
 272            permissions = permissions.Where(x => x.Name.ToLower().Contains(name));
 73        }
 74
 1475        var projected = permissions.Select(x => new ScopePermissionOutput
 1476        {
 1477            Id = x.PublicId,
 1478            Name = x.Name,
 1479            Description = x.Description,
 1480            IncludeAsJwtClaim = x.IncludeAsJwtClaim,
 1481            ScopeId = x.Scope.PublicId,
 1482            IsDeleted = x.IsDeleted,
 1483            CreatedAt = x.CreatedAt,
 1484            UpdatedAt = x.UpdatedAt
 1485        });
 86
 87        // Ordered by name with the public identifier as a tiebreaker, then paginated over that
 88        // ordering rather than handing PaginateAsync a sort key of its own.
 89        //
 90        // Name is not unique, and PostgreSQL gives no ordering guarantee between rows whose sort key
 91        // ties — each page is a separate query, free to break the tie differently. Two people called
 92        // "Ana Silva" straddling a page boundary could therefore appear on both pages while somebody
 93        // else appeared on neither. The tiebreaker makes the total order deterministic, so paging
 94        // through a list sees every row exactly once.
 1495        var ordered = projected.OrderBy(x => x.Name).ThenBy(x => x.Id);
 96
 1497        var page = await ordered.PaginateAsync(query.PageNumber, query.PageSize, orderBy: null);
 98
 1499        return page.WithMessage(ScopePermissionMessages.ScopePermissionsRetrievedSuccessfully);
 21100    }
 101}