< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.DeletePersonCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/DeletePersonCommandHandler.cs
Line coverage
98%
Covered lines: 50
Uncovered lines: 1
Coverable lines: 51
Total lines: 170
Line coverage: 98%
Branch coverage
96%
Covered branches: 27
Total branches: 28
Branch coverage: 96.4%
Method coverage
100%
Covered methods: 3
Fully covered methods: 2
Total methods: 3
Method coverage: 100%
Full method coverage: 66.6%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()90%212088.88%
MayDeleteAsync()100%88100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/DeletePersonCommandHandler.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.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="DeletePersonCommand" /> (UC-09): locates the person (AF-09a), refuses a
 18///     self-deletion (AF-09d), enforces the per-actor rule (AF-09c), serves an already-deleted person
 19///     as an idempotent no-op (AF-09b), and otherwise sets <c>IsDeleted = true</c> — provided no scope
 20///     would be left without an owner (AF-09e, NFR-12) and the system not without an active System Admin
 21///     (AF-09f, NFR-32). Nothing cascades: the person's join rows,
 22///     tokens, and owned applications are untouched. All failures are returned as errors on the
 23///     <see cref="DataOutput{T}" /> rather than thrown.
 24/// </summary>
 25/// <remarks>
 26///     <b>Under the locks of the person's scopes and of the System Admin set</b> (#202, #203). The guards
 27///     and the deletion run in one transaction holding the rows of the scopes the person owns, and then
 28///     the System Admin set (<see cref="IInvariantLocks" />), and decide on the person as read under them
 29///     (<see cref="LockedPerson" />), as every writer that can take an owner or a System Admin out does. Deleting one o
 30///     removed or deleted therefore runs one after the other, and the second is refused, where both used
 31///     to see the other still there.
 32/// </remarks>
 3733public class DeletePersonCommandHandler(
 3734    IAsyncReadOnlyRepository<Person, long> personReader,
 3735    IScopeOwnershipChecker scopeOwnership,
 3736    IAtomicWrites atomicWrites,
 3737    IWriteTransactions transactions,
 3738    IInvariantLocks locks)
 39    : ICommandHandlerAsync<DeletePersonCommand, DeletePersonCommandOutput>
 40{
 41    public async Task<DataOutput<DeletePersonCommandOutput?>> HandleAsync(DeletePersonCommand command, CancellationToken
 42    {
 3743        var output = DataOutput<DeletePersonCommandOutput?>.New;
 44
 45        // AF-09a. The lookup deliberately omits an !IsDeleted filter (unlike UC-08) so an
 46        // already-deleted person is found and served idempotently by AF-09b below. ScopeMembership is
 47        // needed by the authorization rule; the ownerships the last-owner guard needs are read under its
 48        // locks.
 3749        var person = await personReader.Query()
 3750            .Include(x => x.ScopeMembership)
 3751            .FirstOrDefaultAsync(x => x.PublicId == command.Id);
 52
 3753        if (person is null)
 54        {
 255            return output.WithError(PersonMessages.PersonNotFound);
 56        }
 57
 58        // AF-09d: nobody deletes their own record, System Admin included, so one call cannot lock an
 59        // administrator out. Checked before authorization, which a System Admin would otherwise pass.
 3560        if (command.ActingPersonId == person.PublicId)
 61        {
 362            return output.WithError(PersonMessages.CannotDeleteSelf);
 63        }
 64
 65        // UC-09 step 2 (AF-09c). Runs before AF-09b so an already-deleted person cannot be used to
 66        // probe for the existence of persons outside the actor's scopes.
 3267        if (!await MayDeleteAsync(command, person))
 68        {
 469            return output.WithError(PersonMessages.NotAuthorizedToDeletePerson);
 70        }
 71
 72        // AF-09b: already deleted, so there is nothing to write. Checked before the last-owner guard —
 73        // such an owner is already out of the scope, and re-running the guard would turn a required
 74        // idempotent success into a conflict.
 2875        if (person.IsDeleted)
 76        {
 377            return output
 378                .WithData(new DeletePersonCommandOutput { Id = person.PublicId, AlreadyDeleted = true })
 379                .WithMessage(PersonMessages.PersonDeletedSuccessfully);
 80        }
 81
 82        // Steps 3-4 under the locks of the person's scopes (#202): leaving this block before the commit
 83        // rolls back.
 2584        await using var transaction = await transactions.BeginAsync();
 85
 86        // The person as they stand under the locks (#202, #203): the guards decide on this, not on the
 87        // copy read above.
 2588        var locked = await LockedPerson.LockAsync(locks, personReader, person.Id);
 89
 2590        if (locked is null)
 91        {
 092            return output.WithError(PersonMessages.PersonNotFound);
 93        }
 94
 95        // AF-09b again: a deletion that committed since the read above leaves nothing to do, and the
 96        // guards below would only turn the idempotent answer into a conflict.
 2597        if (locked.IsDeleted)
 98        {
 199            return output
 1100                .WithData(new DeletePersonCommandOutput { Id = person.PublicId, AlreadyDeleted = true })
 1101                .WithMessage(PersonMessages.PersonDeletedSuccessfully);
 102        }
 103
 104        // AF-09e (NFR-12): a soft-deleted owner can no longer authenticate, so deleting the last one
 105        // would leave the scope effectively ownerless. Asked under the locks, so it is still true when
 106        // the deletion below is written.
 24107        if (await LastScopeOwnerGuard.WouldStripLastOwnerAsync(person.Id, locked.OwnedScopeIds, personReader))
 108        {
 6109            return output.WithError(PersonMessages.ScopeWouldLoseLastOwner);
 110        }
 111
 112        // AF-09f (NFR-32): nor may it leave the system without an active System Admin. AF-09d already
 113        // keeps an administrator from deleting themselves; this is the other one, deleted by someone
 114        // who — restricted, or deleting them at the same moment — would leave none.
 18115        if (locked.IsLiveSystemAdmin && await LastSystemAdminGuard.WouldLeaveNoneAsync(person.Id, personReader))
 116        {
 4117            return output.WithError(PersonMessages.WouldRemoveLastSystemAdmin);
 118        }
 119
 120        // UC-09 step 3: flip the flag and stamp UpdatedAt (no DB trigger maintains it).
 121        //
 122        // DeletedAt and DeletionKind are stamped alongside it for NFR-20: the window that ends in
 123        // anonymisation is measured from the deletion, and UpdatedAt cannot serve as that mark
 124        // because any later write moves it and would silently restart the window. The kind is
 125        // Administrative because this is an administrator's decision — no data subject asked — and
 126        // that carries the longer of the schedule's two deadlines; UC-42 writes SubjectRequested.
 127        //
 128        // A targeted write of those four columns alone (#237), and only while the person is live:
 129        // a deletion that landed since the read stands as it was, and this one answers as AF-09b
 130        // does, since the outcome the caller asked for is already the case.
 14131        var deleted = await atomicWrites.TryMarkDeletedAsync(person, DeletionKinds.Administrative, DateTime.UtcNow);
 132
 14133        if (!deleted)
 134        {
 1135            return output
 1136                .WithData(new DeletePersonCommandOutput { Id = person.PublicId, AlreadyDeleted = true })
 1137                .WithMessage(PersonMessages.PersonDeletedSuccessfully);
 138        }
 139
 13140        await transaction.CommitAsync();
 141
 142        // UC-09 step 4.
 13143        return output
 13144            .WithData(new DeletePersonCommandOutput { Id = person.PublicId, AlreadyDeleted = false })
 13145            .WithMessage(PersonMessages.PersonDeletedSuccessfully);
 37146    }
 147
 148    /// <summary>
 149    ///     UC-09 step 2. A System Admin may delete any person; a Scope Admin may delete only a
 150    ///     <c>User</c> belonging to a scope they own. Everything else is denied.
 151    /// </summary>
 152    private async Task<bool> MayDeleteAsync(DeletePersonCommand command, Person person)
 153    {
 32154        if (command.ActingRole == (int)Roles.SystemAdmin)
 155        {
 26156            return true;
 157        }
 158
 6159        if (command.ActingRole != (int)Roles.ScopeAdmin ||
 6160            person.RoleId != (long)Roles.User ||
 6161            person.ScopeMembership is null)
 162        {
 2163            return false;
 164        }
 165
 4166        return await scopeOwnership.ActorMayManageScopeAsync(
 4167            command.ActingRole, command.ActingPersonId, person.ScopeMembership.ScopeId);
 32168    }
 169
 170}