< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.PromoteScopeUserCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/PromoteScopeUserCommandHandler.cs
Line coverage
98%
Covered lines: 51
Uncovered lines: 1
Coverable lines: 52
Total lines: 130
Line coverage: 98%
Branch coverage
94%
Covered branches: 17
Total branches: 18
Branch coverage: 94.4%
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()94.44%181897.87%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/PromoteScopeUserCommandHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Command.Input;
 3using ArturRios.Heimdall.Command.Output;
 4using ArturRios.Heimdall.Domain.Entities;
 5using ArturRios.Heimdall.Domain.Enums;
 6using ArturRios.Heimdall.Shared.Messages;
 7using ArturRios.Heimdall.Shared.Services;
 8using ArturRios.Mediator.Command.Interfaces;
 9using ArturRios.Output;
 10using Microsoft.EntityFrameworkCore;
 11
 12namespace ArturRios.Heimdall.Command.Handlers;
 13
 14/// <summary>
 15///     Handles <see cref="PromoteScopeUserCommand" /> (UC-23, FR-SC-08/FR-SC-13/FR-RO-03): verifies
 16///     the target scope exists and is active (AF-23a), enforces scope ownership for a Scope Admin
 17///     actor (AF-23c), refuses a person who already holds <c>ScopeAdmin</c> (AF-23d), verifies the
 18///     named person is a non-deleted <c>User</c> of that scope (AF-23b), then promotes them — role to
 19///     <c>ScopeAdmin</c>, <c>SCOPE_USER</c> row removed, <c>SCOPE_OWNER</c> row added — in one write,
 20///     so FR-PE-11 is never observably violated. A System Admin actor bypasses the ownership check.
 21///     All failures are returned as errors on the <see cref="DataOutput{T}" /> rather than thrown.
 22/// </summary>
 2823public class PromoteScopeUserCommandHandler(
 2824    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 2825    IAsyncReadOnlyRepository<Person, long> personReader,
 2826    IAsyncRepository<Person, long> personWriter,
 2827    IScopeOwnershipChecker scopeOwnership)
 28    : ICommandHandlerAsync<PromoteScopeUserCommand, PromoteScopeUserCommandOutput>
 29{
 30    public async Task<DataOutput<PromoteScopeUserCommandOutput?>> HandleAsync(PromoteScopeUserCommand command, Cancellat
 31    {
 2832        var output = DataOutput<PromoteScopeUserCommandOutput?>.New;
 33
 34        // AF-23a (UC-23 step 2): the target scope must exist and not be logically deleted — the
 35        // alternative flow names both conditions as one outcome, so the filter answers for both.
 2836        var scope = await scopeReader.Query()
 2837            .FirstOrDefaultAsync(x => x.PublicId == command.ScopeId && !x.IsDeleted);
 38
 2839        if (scope is null)
 40        {
 441            return output.WithError(PersonMessages.ScopeNotFound);
 42        }
 43
 44        // AF-23c: a Scope Admin actor may only act on a scope they own; a System Admin bypasses.
 45        // Checked before the person is looked up so a caller who fails here learns nothing about
 46        // which person ids exist, what roles they hold, or which scope they belong to.
 2447        if (!await scopeOwnership.ActorMayManageScopeAsync(command.ActingRole, command.ActingPersonId, scope.Id))
 48        {
 349            return output.WithError(PersonMessages.NotScopeOwner);
 50        }
 51
 52        // The person must exist and not be logically deleted (AF-23b): a deleted person can no longer
 53        // authenticate, so the ownership granted could never be exercised. Both scope navigations are
 54        // included because severing one and adding to the other is how the join rows move below.
 2155        var person = await personReader.Query()
 2156            .Include(x => x.ScopeMembership)
 2157            .Include(x => x.ScopeOwnerships)
 2158            .FirstOrDefaultAsync(x => x.PublicId == command.PersonId && !x.IsDeleted);
 59
 2160        if (person is null)
 61        {
 462            return output.WithError(PersonMessages.PersonNotScopeUser);
 63        }
 64
 65        // AF-23d: a person who already holds ScopeAdmin has nothing to be promoted to. Checked before
 66        // AF-23b because such a person also satisfies "not a User of that scope" — the more specific
 67        // flow has to win, or AF-23d would be unreachable.
 1768        if (person.RoleId == (long)Roles.ScopeAdmin)
 69        {
 370            return output.WithError(PersonMessages.AlreadyScopeAdmin);
 71        }
 72
 73        // AF-23b (UC-23 step 3): the person must be a User *of this scope*. An unknown id, a User of
 74        // another scope, and a System Admin all answer alike, so the endpoint cannot be used to probe
 75        // which persons exist or where they belong.
 1476        if (person.RoleId != (long)Roles.User ||
 1477            person.ScopeMembership is null ||
 1478            person.ScopeMembership.ScopeId != scope.Id)
 79        {
 380            return output.WithError(PersonMessages.PersonNotScopeUser);
 81        }
 82
 83        // FR-PE-09: the promotion moves the address from the scope's User namespace into the
 84        // system-wide admin namespace, where it must be unique. Compared case-insensitively
 85        // (LOWER() in SQL) and ignoring logically deleted admins, as UC-06 path c does.
 1186        var email = person.Email.ToLower();
 87
 1188        var emailTaken = await personReader.Query().AnyAsync(other =>
 1189            other.Id != person.Id && !other.IsDeleted && other.Email.ToLower() == email &&
 1190            (other.RoleId == (long)Roles.SystemAdmin || other.RoleId == (long)Roles.ScopeAdmin));
 91
 1192        if (emailTaken)
 93        {
 294            return output.WithError(PersonMessages.EmailAlreadyExists);
 95        }
 96
 97        // UC-23 steps 4-5, in one write. Neither join entity has a repository of its own, so both
 98        // rows move through the person aggregate: severing the required ScopeMembership deletes the
 99        // SCOPE_USER row (as UC-08's role change does) and adding to ScopeOwnerships writes the
 100        // SCOPE_OWNER row (as UC-21 does). UpdatedAt is stamped by hand — no trigger maintains it.
 9101        person.RoleId = (long)Roles.ScopeAdmin;
 9102        person.ScopeMembership = null;
 9103        person.ScopeId = null;
 9104        person.ScopeOwnerships.Add(new ScopeOwner { ScopeId = scope.Id, PersonId = person.Id });
 9105        person.UpdatedAt = DateTime.UtcNow;
 106
 9107        var update = await personWriter.UpdateAsync(person);
 108
 9109        if (!update.Success)
 110        {
 0111            return output.WithErrors(update.Errors);
 112        }
 113
 114        // UC-23 step 6: return the updated person. The scope just granted is the whole of what they
 115        // own — they were a User, and a User owns no scope (FR-SC-08, FR-PE-11).
 9116        return output
 9117            .WithData(new PromoteScopeUserCommandOutput
 9118            {
 9119                Id = person.PublicId,
 9120                Name = person.Name,
 9121                Email = person.Email,
 9122                Role = (int)Roles.ScopeAdmin,
 9123                EmailVerified = person.EmailVerified,
 9124                OwnedScopeIds = [scope.PublicId],
 9125                CreatedAt = person.CreatedAt,
 9126                UpdatedAt = person.UpdatedAt
 9127            })
 9128            .WithMessage(PersonMessages.ScopeUserPromotedSuccessfully);
 28129    }
 130}