< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.AddScopeOwnerCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/AddScopeOwnerCommandHandler.cs
Line coverage
97%
Covered lines: 36
Uncovered lines: 1
Coverable lines: 37
Total lines: 102
Line coverage: 97.2%
Branch coverage
87%
Covered branches: 7
Total branches: 8
Branch coverage: 87.5%
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%101096.77%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/AddScopeOwnerCommandHandler.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="AddScopeOwnerCommand" /> (UC-21, FR-SC-08/FR-SC-09): verifies the target
 16///     scope exists and is active (AF-21a), enforces scope ownership for a Scope Admin actor
 17///     (AF-21c), verifies the named person is an existing, non-deleted <c>ScopeAdmin</c> or
 18///     <c>SystemAdmin</c> (AF-21b), then links them to the scope with a <c>SCOPE_OWNER</c> row —
 19///     serving a person who already owns it as an idempotent no-op (AF-21d). A System Admin actor
 20///     bypasses the ownership check. All failures are returned as errors on the
 21///     <see cref="DataOutput{T}" /> rather than thrown.
 22/// </summary>
 2323public class AddScopeOwnerCommandHandler(
 2324    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 2325    IAsyncReadOnlyRepository<Person, long> personReader,
 2326    IAsyncRepository<Person, long> personWriter,
 2327    IScopeOwnershipChecker scopeOwnership)
 28    : ICommandHandlerAsync<AddScopeOwnerCommand, AddScopeOwnerCommandOutput>
 29{
 30    public async Task<DataOutput<AddScopeOwnerCommandOutput?>> HandleAsync(AddScopeOwnerCommand command, CancellationTok
 31    {
 2332        var output = DataOutput<AddScopeOwnerCommandOutput?>.New;
 33
 34        // AF-21a (UC-21 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.
 2336        var scope = await scopeReader.Query()
 2337            .FirstOrDefaultAsync(x => x.PublicId == command.ScopeId && !x.IsDeleted);
 38
 2339        if (scope is null)
 40        {
 441            return output.WithError(PersonMessages.ScopeNotFound);
 42        }
 43
 44        // AF-21c: 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 or what roles they hold.
 1947        if (!await scopeOwnership.ActorMayManageScopeAsync(command.ActingRole, command.ActingPersonId, scope.Id))
 48        {
 349            return output.WithError(PersonMessages.NotScopeOwner);
 50        }
 51
 52        // AF-21b (UC-21 step 3): the person must exist, not be logically deleted, and hold an
 53        // administrator role — ScopeAdmin or SystemAdmin (FR-SC-08). A deleted person can no longer
 54        // authenticate, so an ownership granted to them could never be exercised. All three
 55        // conditions share one answer, so the endpoint cannot be used to tell an unknown id from a
 56        // User. The ownership rows are included because adding to that collection is how the join
 57        // row gets written below.
 1658        var person = await personReader.Query()
 1659            .Include(x => x.ScopeOwnerships)
 1660            .FirstOrDefaultAsync(x =>
 1661                x.PublicId == command.PersonId && !x.IsDeleted &&
 1662                (x.RoleId == (long)Roles.ScopeAdmin || x.RoleId == (long)Roles.SystemAdmin));
 63
 1664        if (person is null)
 65        {
 666            return output.WithError(PersonMessages.PersonNotValidScopeAdmin);
 67        }
 68
 69        // AF-21d: already an owner, so there is nothing to write — UC-21 step 4 calls the insert a
 70        // no-op in that case. Writing anyway would violate the (ScopeId, PersonId) composite key.
 1371        if (person.ScopeOwnerships.Any(ownership => ownership.ScopeId == scope.Id))
 72        {
 273            return output
 274                .WithData(new AddScopeOwnerCommandOutput
 275                {
 276                    ScopeId = scope.PublicId, PersonId = person.PublicId, AlreadyOwner = true
 277                })
 278                .WithMessage(PersonMessages.AlreadyScopeOwner);
 79        }
 80
 81        // UC-21 step 4: insert the SCOPE_OWNER row. ScopeOwner is a join entity with no repository of
 82        // its own, so the row is written through the person aggregate — the symmetric operation to
 83        // UC-08 clearing ScopeOwnerships to delete rows. FR-PE-11 needs no guard here: the person is
 84        // already an administrator and this only ever increases the scopes they own.
 885        person.ScopeOwnerships.Add(new ScopeOwner { ScopeId = scope.Id, PersonId = person.Id });
 86
 887        var update = await personWriter.UpdateAsync(person);
 88
 889        if (!update.Success)
 90        {
 091            return output.WithErrors(update.Errors);
 92        }
 93
 94        // UC-21 step 5.
 895        return output
 896            .WithData(new AddScopeOwnerCommandOutput
 897            {
 898                ScopeId = scope.PublicId, PersonId = person.PublicId, AlreadyOwner = false
 899            })
 8100            .WithMessage(PersonMessages.ScopeOwnerAddedSuccessfully);
 23101    }
 102}