< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.RemoveScopeOwnerCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/RemoveScopeOwnerCommandHandler.cs
Line coverage
96%
Covered lines: 30
Uncovered lines: 1
Coverable lines: 31
Total lines: 97
Line coverage: 96.7%
Branch coverage
91%
Covered branches: 11
Total branches: 12
Branch coverage: 91.6%
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()92.85%141496%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/RemoveScopeOwnerCommandHandler.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.Shared.Messages;
 6using ArturRios.Heimdall.Shared.Services;
 7using ArturRios.Mediator.Command.Interfaces;
 8using ArturRios.Output;
 9using Microsoft.EntityFrameworkCore;
 10
 11namespace ArturRios.Heimdall.Command.Handlers;
 12
 13/// <summary>
 14///     Handles <see cref="RemoveScopeOwnerCommand" /> (UC-22, FR-SC-08/FR-SC-10): verifies the target
 15///     scope exists and is active (AF-22a), enforces scope ownership for a Scope Admin actor (AF-22c),
 16///     verifies the named person actually holds a <c>SCOPE_OWNER</c> row for that scope (AF-22a),
 17///     refuses to strip the scope of its last live owner (AF-22b, NFR-12), then removes the join row.
 18///     A System Admin actor bypasses the ownership check. All failures are returned as errors on the
 19///     <see cref="DataOutput{T}" /> rather than thrown.
 20/// </summary>
 2421public class RemoveScopeOwnerCommandHandler(
 2422    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 2423    IAsyncReadOnlyRepository<Person, long> personReader,
 2424    IAsyncRepository<Person, long> personWriter,
 2425    IScopeOwnershipChecker scopeOwnership)
 26    : ICommandHandlerAsync<RemoveScopeOwnerCommand, RemoveScopeOwnerCommandOutput>
 27{
 28    public async Task<DataOutput<RemoveScopeOwnerCommandOutput?>> HandleAsync(RemoveScopeOwnerCommand command, Cancellat
 29    {
 2430        var output = DataOutput<RemoveScopeOwnerCommandOutput?>.New;
 31
 32        // AF-22a (UC-22 step 2): the target scope must exist. The !IsDeleted filter answers alike for a
 33        // logically deleted one — rewriting the ownership of a scope withdrawn from service is not
 34        // something UC-22 promises, and every scope-scoped handler treats it as absent.
 2435        var scope = await scopeReader.Query()
 2436            .FirstOrDefaultAsync(x => x.PublicId == command.ScopeId && !x.IsDeleted);
 37
 2438        if (scope is null)
 39        {
 440            return output.WithError(PersonMessages.ScopeNotFound);
 41        }
 42
 43        // AF-22c: a Scope Admin actor may only act on a scope they own; a System Admin bypasses.
 44        // Checked before the person is looked up so a caller who fails here learns nothing about which
 45        // person ids exist or who owns the scope.
 2046        if (!await scopeOwnership.ActorMayManageScopeAsync(command.ActingRole, command.ActingPersonId, scope.Id))
 47        {
 348            return output.WithError(PersonMessages.NotScopeOwner);
 49        }
 50
 51        // AF-22a (UC-22 step 2): the person must hold an ownership row for this scope. Neither
 52        // !IsDeleted nor a role filter applies — what matters is only whether the SCOPE_OWNER row
 53        // exists. A logically deleted ScopeAdmin keeps their ownership rows (UC-09 cascades nothing),
 54        // and clearing such a stale row is exactly what this endpoint is for.
 1755        var person = await personReader.Query()
 1756            .Include(x => x.ScopeOwnerships)
 1757            .FirstOrDefaultAsync(x => x.PublicId == command.PersonId);
 58
 3159        var ownership = person?.ScopeOwnerships.FirstOrDefault(x => x.ScopeId == scope.Id);
 60
 1761        if (person is null || ownership is null)
 62        {
 563            return output.WithError(PersonMessages.PersonNotScopeOwner);
 64        }
 65
 66        // AF-22b (UC-22 step 3, NFR-12): somebody else must be left owning the scope. Logically deleted
 67        // co-owners do not count — they can no longer authenticate (FR-AU-07), so they do not keep a
 68        // scope owned; without that exclusion an ownerless scope could be created by removing the only
 69        // live owner while a deleted one remained on the row.
 1270        var hasLiveCoOwner = await personReader.Query().AnyAsync(other =>
 1271            other.Id != person.Id && !other.IsDeleted &&
 1272            other.ScopeOwnerships.Any(x => x.ScopeId == scope.Id));
 73
 1274        if (!hasLiveCoOwner)
 75        {
 376            return output.WithError(PersonMessages.ScopeWouldLoseLastOwner);
 77        }
 78
 79        // UC-22 step 4: delete the SCOPE_OWNER row. ScopeOwner is a join entity with no repository of
 80        // its own, so the row is removed through the person aggregate — the exact inverse of UC-21's
 81        // Add, and the single-row form of the Clear() UC-08 uses. FR-PE-11 is deliberately not guarded
 82        // here: SRD §8 names UC-22 as an operation that may leave a ScopeAdmin owning no scope.
 983        person.ScopeOwnerships.Remove(ownership);
 84
 985        var update = await personWriter.UpdateAsync(person);
 86
 987        if (!update.Success)
 88        {
 089            return output.WithErrors(update.Errors);
 90        }
 91
 92        // UC-22 step 5.
 993        return output
 994            .WithData(new RemoveScopeOwnerCommandOutput { ScopeId = scope.PublicId, PersonId = person.PublicId })
 995            .WithMessage(PersonMessages.ScopeOwnerRemovedSuccessfully);
 2496    }
 97}