< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Query.Handlers.GetPersonByIdQueryHandler
Assembly: ArturRios.Heimdall.Query
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Query/Handlers/GetPersonByIdQueryHandler.cs
Line coverage
100%
Covered lines: 58
Uncovered lines: 0
Coverable lines: 58
Total lines: 142
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/ci/agent/workspace/heimdall-api-docs_main/app/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>
 6422public 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    {
 14131        public Guid PublicId { get; init; }
 32
 7333        public long RoleId { get; init; }
 34
 7035        public long? MembershipScopeId { get; init; }
 36
 17737        public List<OwnedScope> OwnedScopes { get; init; } = [];
 38
 16839        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    {
 6455        var output = DataOutput<PersonOutput?>.New;
 56
 6457        var person = await personReader.Query()
 6458            .Where(x => x.PublicId == query.Id && (query.IncludeDeleted || !x.IsDeleted))
 6459            .Select(x => new PersonProjection
 6460            {
 6461                PublicId = x.PublicId,
 6462                RoleId = x.RoleId,
 6463                MembershipScopeId = x.ScopeMembership != null ? x.ScopeMembership.ScopeId : null,
 6464                OwnedScopes = x.ScopeOwnerships
 6465                    .Select(ownership => new OwnedScope { ScopeId = ownership.ScopeId, PublicId = ownership.Scope.Public
 6466                    .ToList(),
 6467                Output = new PersonOutput
 6468                {
 6469                    Id = x.PublicId,
 6470                    Name = x.Name,
 6471                    Email = x.Email,
 6472                    Role = (int)x.RoleId,
 6473                    EmailVerified = x.EmailVerified,
 6474                    TwoFactorEnabled = x.TwoFactorAuth != null && x.TwoFactorAuth.IsActive,
 6475                    IsDeleted = x.IsDeleted,
 6476                    ScopeId = x.ScopeMembership != null ? x.ScopeMembership.Scope.PublicId : null,
 6477                    CreatedAt = x.CreatedAt,
 6478                    UpdatedAt = x.UpdatedAt
 6479                }
 6480            })
 6481            .FirstOrDefaultAsync(cancellationToken);
 82
 83        // AF-07a: no such person (or it is logically deleted and was not explicitly requested).
 6484        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).
 6091        var visibleScopeIds = query.ActingPersonId == person.PublicId
 6092            ? null
 6093            : await OwnedScopeVisibility.ScopesVisibleToAsync(
 6094                personReader, query.ActingRole, query.ActingPersonId, cancellationToken);
 95
 96        // AF-07b: the caller is not allowed to see this person.
 6097        if (!MayView(query, person, visibleScopeIds))
 98        {
 699            return output.WithError(PersonMessages.NotAuthorizedToViewPerson);
 100        }
 101
 54102        person.Output.OwnedScopeIds = person.OwnedScopes
 13103            .Where(scope => visibleScopeIds is null || visibleScopeIds.Contains(scope.ScopeId))
 11104            .Select(scope => scope.PublicId)
 54105            .ToList();
 106
 54107        return output
 54108            .WithData(person.Output)
 54109            .WithMessage(PersonMessages.PersonRetrievedSuccessfully);
 64110    }
 111
 112    /// <summary>
 113    ///     UC-07 step 2: a System Admin sees anyone; anyone sees themselves; a Scope Admin sees the
 114    ///     Users of the scopes they own and the Scope Admins co-owning those scopes. Everything else
 115    ///     is denied — in particular a User seeing another person, and a Scope Admin seeing a System
 116    ///     Admin or an unrelated Scope Admin. <paramref name="ownedScopeIds" /> are the scopes the
 117    ///     actor owns, or <c>null</c> for a System Admin or a self read.
 118    /// </summary>
 119    private static bool MayView(GetPersonByIdQuery query, PersonProjection person, IReadOnlyCollection<long>? ownedScope
 120    {
 60121        if (query.ActingRole == (int)Roles.SystemAdmin || query.ActingPersonId == person.PublicId)
 122        {
 49123            return true;
 124        }
 125
 11126        if (query.ActingRole != (int)Roles.ScopeAdmin || ownedScopeIds is null)
 127        {
 2128            return false;
 129        }
 130
 131        // ownedScopeIds are the actor's own ownerships. A logically deleted actor owns nothing: they
 132        // can no longer authenticate (UC-11 AF-11c), so a token issued before their deletion must
 133        // not keep granting reads until it expires.
 9134        if (person.RoleId == (long)Roles.User)
 135        {
 5136            return person.MembershipScopeId is not null && ownedScopeIds.Contains(person.MembershipScopeId.Value);
 137        }
 138
 4139        return person.RoleId == (long)Roles.ScopeAdmin &&
 7140               person.OwnedScopes.Any(scope => ownedScopeIds.Contains(scope.ScopeId));
 141    }
 142}