< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Query.Handlers.GetPersonByIdQueryHandler
Assembly: ArturRios.Heimdall.Query
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Query/Handlers/GetPersonByIdQueryHandler.cs
Line coverage
100%
Covered lines: 57
Uncovered lines: 0
Coverable lines: 57
Total lines: 141
Line coverage: 100%
Branch coverage
95%
Covered branches: 21
Total branches: 22
Branch coverage: 95.4%
Method coverage
100%
Covered methods: 10
Fully covered methods: 10
Total methods: 10
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_PublicId()100%11100%
get_RoleId()100%11100%
get_MembershipScopeId()100%11100%
get_OwnedScopes()100%11100%
get_Output()100%11100%
get_ScopeId()100%11100%
get_PublicId()100%11100%
HandleAsync()100%66100%
MayView(...)92.85%1414100%

File(s)

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

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Domain.Entities;
 3using ArturRios.Heimdall.Domain.Enums;
 4using ArturRios.Heimdall.Query.Input;
 5using ArturRios.Heimdall.Query.Output;
 6using ArturRios.Heimdall.Shared.Messages;
 7using ArturRios.Heimdall.Shared.Services;
 8using ArturRios.Mediator.Query.Interfaces;
 9using ArturRios.Output;
 10using Microsoft.EntityFrameworkCore;
 11
 12namespace ArturRios.Heimdall.Query.Handlers;
 13
 14/// <summary>
 15///     Handles <see cref="GetPersonByIdQuery" /> (UC-07, FR-PE-03): retrieves a person by their
 16///     <c>PublicId</c>, excluding logically deleted persons unless explicitly requested (FR-PE-08),
 17///     then applies the use case's per-actor visibility rule. A missing person is AF-07a
 18///     (<c>PersonNotFound</c>); a person the caller may not see is AF-07b
 19///     (<c>NotAuthorizedToViewPerson</c>). Both are returned as errors rather than thrown. A Scope
 20///     Admin reading somebody else sees only the ownerships of scopes they own too (FR-PE-19).
 21/// </summary>
 6322public class GetPersonByIdQueryHandler(IAsyncReadOnlyRepository<Person, long> personReader)
 23    : IQueryHandlerAsync<GetPersonByIdQuery, PersonOutput>
 24{
 25    /// <summary>
 26    ///     The person plus the internal ids the visibility rule needs. Internal ids never reach the
 27    ///     caller — only <see cref="Output" /> is returned.
 28    /// </summary>
 29    private sealed class PersonProjection
 30    {
 13931        public Guid PublicId { get; init; }
 32
 7233        public long RoleId { get; init; }
 34
 6935        public long? MembershipScopeId { get; init; }
 36
 17437        public List<OwnedScope> OwnedScopes { get; init; } = [];
 38
 16539        public PersonOutput Output { get; init; } = null!;
 40    }
 41
 42    /// <summary>
 43    ///     One scope the person owns, by both ids: the internal one to compare, the public one to
 44    ///     report.
 45    /// </summary>
 46    private sealed class OwnedScope
 47    {
 2148        public long ScopeId { get; init; }
 49
 2450        public Guid PublicId { get; init; }
 51    }
 52
 53    public async Task<DataOutput<PersonOutput?>> HandleAsync(GetPersonByIdQuery query, CancellationToken cancellationTok
 54    {
 6355        var output = DataOutput<PersonOutput?>.New;
 56
 6357        var person = await personReader.Query()
 6358            .Where(x => x.PublicId == query.Id && (query.IncludeDeleted || !x.IsDeleted))
 6359            .Select(x => new PersonProjection
 6360            {
 6361                PublicId = x.PublicId,
 6362                RoleId = x.RoleId,
 6363                MembershipScopeId = x.ScopeMembership != null ? x.ScopeMembership.ScopeId : null,
 6364                OwnedScopes = x.ScopeOwnerships
 6365                    .Select(ownership => new OwnedScope { ScopeId = ownership.ScopeId, PublicId = ownership.Scope.Public
 6366                    .ToList(),
 6367                Output = new PersonOutput
 6368                {
 6369                    Id = x.PublicId,
 6370                    Name = x.Name,
 6371                    Email = x.Email,
 6372                    Role = (int)x.RoleId,
 6373                    EmailVerified = x.EmailVerified,
 6374                    TwoFactorEnabled = x.TwoFactorAuth != null && x.TwoFactorAuth.IsActive,
 6375                    IsDeleted = x.IsDeleted,
 6376                    ScopeId = x.ScopeMembership != null ? x.ScopeMembership.Scope.PublicId : null,
 6377                    CreatedAt = x.CreatedAt,
 6378                    UpdatedAt = x.UpdatedAt
 6379                }
 6380            })
 6381            .FirstOrDefaultAsync();
 82
 83        // AF-07a: no such person (or it is logically deleted and was not explicitly requested).
 6384        if (person is null)
 85        {
 486            return output.WithError(PersonMessages.PersonNotFound);
 87        }
 88
 89        // A System Admin and the person themselves see every ownership; anyone else only those of
 90        // the scopes they own (FR-PE-19).
 5991        var visibleScopeIds = query.ActingPersonId == person.PublicId
 5992            ? null
 5993            : await OwnedScopeVisibility.ScopesVisibleToAsync(personReader, query.ActingRole, query.ActingPersonId);
 94
 95        // AF-07b: the caller is not allowed to see this person.
 5996        if (!MayView(query, person, visibleScopeIds))
 97        {
 698            return output.WithError(PersonMessages.NotAuthorizedToViewPerson);
 99        }
 100
 53101        person.Output.OwnedScopeIds = person.OwnedScopes
 13102            .Where(scope => visibleScopeIds is null || visibleScopeIds.Contains(scope.ScopeId))
 11103            .Select(scope => scope.PublicId)
 53104            .ToList();
 105
 53106        return output
 53107            .WithData(person.Output)
 53108            .WithMessage(PersonMessages.PersonRetrievedSuccessfully);
 63109    }
 110
 111    /// <summary>
 112    ///     UC-07 step 2: a System Admin sees anyone; anyone sees themselves; a Scope Admin sees the
 113    ///     Users of the scopes they own and the Scope Admins co-owning those scopes. Everything else
 114    ///     is denied — in particular a User seeing another person, and a Scope Admin seeing a System
 115    ///     Admin or an unrelated Scope Admin. <paramref name="ownedScopeIds" /> are the scopes the
 116    ///     actor owns, or <c>null</c> for a System Admin or a self read.
 117    /// </summary>
 118    private static bool MayView(GetPersonByIdQuery query, PersonProjection person, IReadOnlyCollection<long>? ownedScope
 119    {
 59120        if (query.ActingRole == (int)Roles.SystemAdmin || query.ActingPersonId == person.PublicId)
 121        {
 48122            return true;
 123        }
 124
 11125        if (query.ActingRole != (int)Roles.ScopeAdmin || ownedScopeIds is null)
 126        {
 2127            return false;
 128        }
 129
 130        // ownedScopeIds are the actor's own ownerships. A logically deleted actor owns nothing: they
 131        // can no longer authenticate (UC-11 AF-11c), so a token issued before their deletion must
 132        // not keep granting reads until it expires.
 9133        if (person.RoleId == (long)Roles.User)
 134        {
 5135            return person.MembershipScopeId is not null && ownedScopeIds.Contains(person.MembershipScopeId.Value);
 136        }
 137
 4138        return person.RoleId == (long)Roles.ScopeAdmin &&
 7139               person.OwnedScopes.Any(scope => ownedScopeIds.Contains(scope.ScopeId));
 140    }
 141}