< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.RemoveScopeOwnerCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/RemoveScopeOwnerCommandHandler.cs
Line coverage
100%
Covered lines: 37
Uncovered lines: 0
Coverable lines: 37
Total lines: 123
Line coverage: 100%
Branch coverage
100%
Covered branches: 16
Total branches: 16
Branch coverage: 100%
Method coverage
100%
Covered methods: 2
Fully covered methods: 2
Total methods: 2
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()100%1818100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/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.Command.Services;
 5using ArturRios.Heimdall.Domain.Entities;
 6using ArturRios.Heimdall.Domain.Persistence;
 7using ArturRios.Heimdall.Shared.Messages;
 8using ArturRios.Heimdall.Shared.Services;
 9using ArturRios.Mediator.Command.Interfaces;
 10using ArturRios.Output;
 11using Microsoft.EntityFrameworkCore;
 12
 13namespace ArturRios.Heimdall.Command.Handlers;
 14
 15/// <summary>
 16///     Handles <see cref="RemoveScopeOwnerCommand" /> (UC-22, FR-SC-08/FR-SC-10): verifies the target
 17///     scope exists and is active (AF-22a), enforces scope ownership for a Scope Admin actor (AF-22c),
 18///     verifies the named person actually holds a <c>SCOPE_OWNER</c> row for that scope (AF-22a),
 19///     refuses to strip the scope of its last live owner (AF-22b, NFR-12), then removes the join row.
 20///     A System Admin actor bypasses the ownership check. All failures are returned as errors on the
 21///     <see cref="DataOutput{T}" /> rather than thrown.
 22/// </summary>
 23/// <remarks>
 24///     <b>Under the scope's lock</b> (#202). The guard and the removal run in one transaction that holds
 25///     the scope's row (<see cref="IInvariantLocks" />), and so does every other writer that can take an
 26///     owner out of it. Two requests removing the scope's last two owners therefore run one after the
 27///     other: the second reads the first's removal and is refused, where both used to see the other
 28///     owner still there and both remove.
 29/// </remarks>
 3430public class RemoveScopeOwnerCommandHandler(
 3431    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 3432    IAsyncReadOnlyRepository<Person, long> personReader,
 3433    IScopeOwnershipChecker scopeOwnership,
 3434    IAtomicWrites atomicWrites,
 3435    IWriteTransactions transactions,
 3436    IInvariantLocks locks)
 37    : ICommandHandlerAsync<RemoveScopeOwnerCommand, RemoveScopeOwnerCommandOutput>
 38{
 39    public async Task<DataOutput<RemoveScopeOwnerCommandOutput?>> HandleAsync(RemoveScopeOwnerCommand command, Cancellat
 40    {
 3441        var output = DataOutput<RemoveScopeOwnerCommandOutput?>.New;
 42
 43        // AF-22a (UC-22 step 2): the target scope must exist. The !IsDeleted filter answers alike for a
 44        // logically deleted one — rewriting the ownership of a scope withdrawn from service is not
 45        // something UC-22 promises, and every scope-scoped handler treats it as absent.
 3446        var scope = await scopeReader.Query()
 3447            .FirstOrDefaultAsync(x => x.PublicId == command.ScopeId && !x.IsDeleted);
 48
 3449        if (scope is null)
 50        {
 451            return output.WithError(PersonMessages.ScopeNotFound);
 52        }
 53
 54        // AF-22c: a Scope Admin actor may only act on a scope they own; a System Admin bypasses.
 55        // Checked before the person is looked up so a caller who fails here learns nothing about which
 56        // person ids exist or who owns the scope.
 3057        if (!await scopeOwnership.ActorMayManageScopeAsync(command.ActingRole, command.ActingPersonId, scope.Id))
 58        {
 359            return output.WithError(PersonMessages.NotScopeOwner);
 60        }
 61
 62        // UC-22 steps 2-4 under the scope's lock (#202): leaving this block before the commit rolls back.
 2763        await using var transaction = await transactions.BeginAsync();
 2764        await locks.LockAsync([scope.Id]);
 65
 66        // Read again under the lock: a deletion of the scope that committed since the read above answers
 67        // AF-22a as it would have a moment earlier.
 2768        if (!await scopeReader.Query().AnyAsync(x => x.Id == scope.Id && !x.IsDeleted))
 69        {
 170            return output.WithError(PersonMessages.ScopeNotFound);
 71        }
 72
 73        // AF-22a (UC-22 step 2): the person must hold an ownership row for this scope. Neither
 74        // !IsDeleted nor a role filter applies — what matters is only whether the SCOPE_OWNER row
 75        // exists. A logically deleted ScopeAdmin keeps their ownership rows (UC-09 cascades nothing),
 76        // and clearing such a stale row is exactly what this endpoint is for.
 2677        var person = await personReader.Query()
 2678            .Include(x => x.ScopeOwnerships)
 2679            .FirstOrDefaultAsync(x => x.PublicId == command.PersonId);
 80
 4981        var ownership = person?.ScopeOwnerships.FirstOrDefault(x => x.ScopeId == scope.Id);
 82
 2683        if (person is null || ownership is null)
 84        {
 585            return output.WithError(PersonMessages.PersonNotScopeOwner);
 86        }
 87
 88        // AF-22b (UC-22 step 3, NFR-12): somebody else must be left owning the scope. Only an active
 89        // co-owner counts (LastScopeOwnerGuard): one logically deleted, restricted or awaiting erasure
 90        // cannot administer the scope (#211), so without that exclusion an ownerless scope could be
 91        // created by removing the only active owner while an inactive one remained on the row.
 2192        if (await LastScopeOwnerGuard.WouldStripLastOwnerAsync(person.Id, [scope.Id], personReader))
 93        {
 894            return output.WithError(PersonMessages.ScopeWouldLoseLastOwner);
 95        }
 96
 97        // UC-22 step 4: delete the SCOPE_OWNER row, by a targeted write of that row alone (#237) rather
 98        // than a save of the person aggregate, which would write the person's profile back as read —
 99        // and this endpoint reads deleted persons too, whose name anonymisation may have replaced
 100        // meanwhile. The write re-checks AF-22b itself; under the lock no owner-removing writer can have
 101        // run since the check above, but a co-owner restricting their own processing (UC-44) takes no
 102        // lock, and the write sees one that committed. FR-PE-11 is deliberately not guarded here: SRD §8
 103        // names UC-22 as an operation that may leave a ScopeAdmin owning no scope.
 13104        if (!await atomicWrites.TryRemoveScopeOwnerAsync(person, scope.Id))
 105        {
 106            // Refused: either another request removed the row meanwhile, which is AF-22a's outcome, or
 107            // the last live co-owner went, which is AF-22b's. The row tells which.
 2108            var ownsStill = await personReader.Query()
 2109                .AnyAsync(x => x.Id == person.Id && x.ScopeOwnerships.Any(owned => owned.ScopeId == scope.Id));
 110
 2111            return output.WithError(ownsStill
 2112                ? PersonMessages.ScopeWouldLoseLastOwner
 2113                : PersonMessages.PersonNotScopeOwner);
 114        }
 115
 11116        await transaction.CommitAsync();
 117
 118        // UC-22 step 5.
 11119        return output
 11120            .WithData(new RemoveScopeOwnerCommandOutput { ScopeId = scope.PublicId, PersonId = person.PublicId })
 11121            .WithMessage(PersonMessages.ScopeOwnerRemovedSuccessfully);
 34122    }
 123}