< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.PromoteScopeUserCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/PromoteScopeUserCommandHandler.cs
Line coverage
97%
Covered lines: 47
Uncovered lines: 1
Coverable lines: 48
Total lines: 135
Line coverage: 97.9%
Branch coverage
95%
Covered branches: 19
Total branches: 20
Branch coverage: 95%
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()95%202097.61%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/PromoteScopeUserCommandHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Data.Relational.Core.Repositories;
 3using ArturRios.Heimdall.Command.Input;
 4using ArturRios.Heimdall.Command.Output;
 5using ArturRios.Heimdall.Domain.Entities;
 6using ArturRios.Heimdall.Domain.Enums;
 7using ArturRios.Heimdall.Domain.Persistence;
 8using ArturRios.Heimdall.Shared.Messages;
 9using ArturRios.Heimdall.Shared.Services;
 10using ArturRios.Mediator.Command.Interfaces;
 11using ArturRios.Output;
 12using Microsoft.EntityFrameworkCore;
 13
 14namespace ArturRios.Heimdall.Command.Handlers;
 15
 16/// <summary>
 17///     Handles <see cref="PromoteScopeUserCommand" /> (UC-23, FR-SC-08/FR-SC-13/FR-RO-03): verifies
 18///     the target scope exists and is active (AF-23a), enforces scope ownership for a Scope Admin
 19///     actor (AF-23c), refuses a person who already holds <c>ScopeAdmin</c> (AF-23d), verifies the
 20///     named person is a non-deleted <c>User</c> of that scope (AF-23b), then promotes them — role to
 21///     <c>ScopeAdmin</c>, <c>SCOPE_USER</c> row removed, <c>SCOPE_OWNER</c> row added — in one transaction,
 22///     so FR-PE-11 is never observably violated. A System Admin actor bypasses the ownership check.
 23///     All failures are returned as errors on the <see cref="DataOutput{T}" /> rather than thrown.
 24/// </summary>
 3225public class PromoteScopeUserCommandHandler(
 3226    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 3227    IAsyncReadOnlyRepository<Person, long> personReader,
 3228    IScopeOwnershipChecker scopeOwnership,
 3229    IAtomicWrites atomicWrites)
 30    : ICommandHandlerAsync<PromoteScopeUserCommand, PromoteScopeUserCommandOutput>
 31{
 32    public async Task<DataOutput<PromoteScopeUserCommandOutput?>> HandleAsync(PromoteScopeUserCommand command, Cancellat
 33    {
 3234        var output = DataOutput<PromoteScopeUserCommandOutput?>.New;
 35
 36        // AF-23a (UC-23 step 2): the target scope must exist and not be logically deleted — the
 37        // alternative flow names both conditions as one outcome, so the filter answers for both.
 3238        var scope = await scopeReader.Query()
 3239            .FirstOrDefaultAsync(x => x.PublicId == command.ScopeId && !x.IsDeleted);
 40
 3241        if (scope is null)
 42        {
 443            return output.WithError(PersonMessages.ScopeNotFound);
 44        }
 45
 46        // AF-23c: a Scope Admin actor may only act on a scope they own; a System Admin bypasses.
 47        // Checked before the person is looked up so a caller who fails here learns nothing about
 48        // which person ids exist, what roles they hold, or which scope they belong to.
 2849        if (!await scopeOwnership.ActorMayManageScopeAsync(command.ActingRole, command.ActingPersonId, scope.Id))
 50        {
 351            return output.WithError(PersonMessages.NotScopeOwner);
 52        }
 53
 54        // The person must exist and not be logically deleted (AF-23b): a deleted person can no longer
 55        // authenticate, so the ownership granted could never be exercised. The membership is included
 56        // because AF-23b reads it.
 2557        var person = await personReader.Query()
 2558            .Include(x => x.ScopeMembership)
 2559            .FirstOrDefaultAsync(x => x.PublicId == command.PersonId && !x.IsDeleted);
 60
 2561        if (person is null)
 62        {
 463            return output.WithError(PersonMessages.PersonNotScopeUser);
 64        }
 65
 66        // AF-23d: a person who already holds ScopeAdmin has nothing to be promoted to. Checked before
 67        // AF-23b because such a person also satisfies "not a User of that scope" — the more specific
 68        // flow has to win, or AF-23d would be unreachable.
 2169        if (person.RoleId == (long)Roles.ScopeAdmin)
 70        {
 371            return output.WithError(PersonMessages.AlreadyScopeAdmin);
 72        }
 73
 74        // AF-23b (UC-23 step 3): the person must be a User *of this scope*. An unknown id, a User of
 75        // another scope, and a System Admin all answer alike, so the endpoint cannot be used to probe
 76        // which persons exist or where they belong.
 1877        if (person.RoleId != (long)Roles.User ||
 1878            person.ScopeMembership is null ||
 1879            person.ScopeMembership.ScopeId != scope.Id)
 80        {
 381            return output.WithError(PersonMessages.PersonNotScopeUser);
 82        }
 83
 84        // FR-PE-09: the promotion moves the address from the scope's User namespace into the
 85        // system-wide admin namespace, where it must be unique. Compared case-insensitively
 86        // (LOWER() in SQL) and ignoring logically deleted admins, as UC-06 path c does.
 1587        var email = person.Email.ToLower();
 88
 1589        var emailTaken = await personReader.Query().AnyAsync(other =>
 1590            other.Id != person.Id && !other.IsDeleted && other.Email.ToLower() == email &&
 1591            (other.RoleId == (long)Roles.SystemAdmin || other.RoleId == (long)Roles.ScopeAdmin));
 92
 1593        if (emailTaken)
 94        {
 295            return output.WithError(PersonMessages.EmailAlreadyExists);
 96        }
 97
 98        // UC-23 steps 4-5, together, and both targeted (#237, #205): the role — with ScopeId, the
 99        // membership's copy, and the SCOPE_USER row removed beside it — made only while the person is
 100        // still the User they were read as, so a promotion, deletion or role change that landed since is
 101        // never overwritten; then, in the same transaction, the SCOPE_OWNER row, as UC-21 writes it. Not
 102        // a save of the person aggregate, which would write the profile back as read here, over a rename
 103        // made meanwhile. UpdatedAt is stamped by hand — no trigger maintains it.
 13104        var change = await atomicWrites.TryChangeEmailAndRoleAsync(
 13105            person, person.Email, (long)Roles.ScopeAdmin, DateTime.UtcNow,
 24106            () => atomicWrites.TryAddScopeOwnerAsync(person, scope.Id));
 107
 108        switch (change)
 109        {
 110            // Not expected — the change has just made them a live Scope Admin in the same transaction —
 111            // but a refused ownership means the person is not as read, which a stale change answers.
 112            case AccountChangeOutcome.SaveFailed:
 113            case AccountChangeOutcome.Stale:
 2114                return output.WithError(RelationalErrors.ConcurrencyMessage);
 115            case AccountChangeOutcome.AddressTaken:
 0116                return output.WithError(RelationalErrors.UniqueViolationMessage);
 117        }
 118
 119        // UC-23 step 6: return the updated person. The scope just granted is the whole of what they
 120        // own — they were a User, and a User owns no scope (FR-SC-08, FR-PE-11).
 11121        return output
 11122            .WithData(new PromoteScopeUserCommandOutput
 11123            {
 11124                Id = person.PublicId,
 11125                Name = person.Name,
 11126                Email = person.Email,
 11127                Role = (int)Roles.ScopeAdmin,
 11128                EmailVerified = person.EmailVerified,
 11129                OwnedScopeIds = [scope.PublicId],
 11130                CreatedAt = person.CreatedAt,
 11131                UpdatedAt = person.UpdatedAt
 11132            })
 11133            .WithMessage(PersonMessages.ScopeUserPromotedSuccessfully);
 32134    }
 135}