< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.DeletePersonCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/DeletePersonCommandHandler.cs
Line coverage
98%
Covered lines: 51
Uncovered lines: 1
Coverable lines: 52
Total lines: 151
Line coverage: 98%
Branch coverage
95%
Covered branches: 21
Total branches: 22
Branch coverage: 95.4%
Method coverage
100%
Covered methods: 4
Fully covered methods: 3
Total methods: 4
Method coverage: 100%
Full method coverage: 75%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()91.66%121296.55%
MayDeleteAsync()100%88100%
WouldStripLastOwnerAsync()100%22100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/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.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="DeletePersonCommand" /> (UC-09): locates the person (AF-09a), refuses a
 16///     self-deletion (AF-09d), enforces the per-actor rule (AF-09c), serves an already-deleted person
 17///     as an idempotent no-op (AF-09b), and otherwise sets <c>IsDeleted = true</c> — provided no scope
 18///     would be left without an owner (AF-09e, NFR-12). Nothing cascades: the person's join rows,
 19///     tokens, and owned applications are untouched. All failures are returned as errors on the
 20///     <see cref="DataOutput{T}" /> rather than thrown.
 21/// </summary>
 2022public class DeletePersonCommandHandler(
 2023    IAsyncReadOnlyRepository<Person, long> personReader,
 2024    IAsyncRepository<Person, long> personWriter,
 2025    IScopeOwnershipChecker scopeOwnership)
 26    : ICommandHandlerAsync<DeletePersonCommand, DeletePersonCommandOutput>
 27{
 28    public async Task<DataOutput<DeletePersonCommandOutput?>> HandleAsync(DeletePersonCommand command, CancellationToken
 29    {
 2030        var output = DataOutput<DeletePersonCommandOutput?>.New;
 31
 32        // AF-09a. The lookup deliberately omits an !IsDeleted filter (unlike UC-08) so an
 33        // already-deleted person is found and served idempotently by AF-09b below. Both scope
 34        // navigations are needed: ScopeMembership by the authorization rule, ScopeOwnerships by the
 35        // last-owner guard.
 2036        var person = await personReader.Query()
 2037            .Include(x => x.ScopeMembership)
 2038            .Include(x => x.ScopeOwnerships)
 2039            .FirstOrDefaultAsync(x => x.PublicId == command.Id);
 40
 2041        if (person is null)
 42        {
 243            return output.WithError(PersonMessages.PersonNotFound);
 44        }
 45
 46        // AF-09d: nobody deletes their own record, System Admin included, so one call cannot lock an
 47        // administrator out. Checked before authorization, which a System Admin would otherwise pass.
 1848        if (command.ActingPersonId == person.PublicId)
 49        {
 250            return output.WithError(PersonMessages.CannotDeleteSelf);
 51        }
 52
 53        // UC-09 step 2 (AF-09c). Runs before AF-09b so an already-deleted person cannot be used to
 54        // probe for the existence of persons outside the actor's scopes.
 1655        if (!await MayDeleteAsync(command, person))
 56        {
 457            return output.WithError(PersonMessages.NotAuthorizedToDeletePerson);
 58        }
 59
 60        // AF-09b: already deleted, so there is nothing to write. Checked before the last-owner guard —
 61        // such an owner is already out of the scope, and re-running the guard would turn a required
 62        // idempotent success into a conflict.
 1263        if (person.IsDeleted)
 64        {
 365            return output
 366                .WithData(new DeletePersonCommandOutput { Id = person.PublicId, AlreadyDeleted = true })
 367                .WithMessage(PersonMessages.PersonDeletedSuccessfully);
 68        }
 69
 70        // AF-09e (NFR-12): a soft-deleted owner can no longer authenticate, so deleting the last one
 71        // would leave the scope effectively ownerless.
 972        if (await WouldStripLastOwnerAsync(person))
 73        {
 374            return output.WithError(PersonMessages.ScopeWouldLoseLastOwner);
 75        }
 76
 77        // UC-09 step 3: flip the flag and stamp UpdatedAt (no DB trigger maintains it).
 78        //
 79        // DeletedAt and DeletionKind are stamped alongside it for NFR-20: the window that ends in
 80        // anonymisation is measured from the deletion, and UpdatedAt cannot serve as that mark
 81        // because any later write moves it and would silently restart the window. The kind is
 82        // Administrative because this is an administrator's decision — no data subject asked — and
 83        // that carries the longer of the schedule's two deadlines. UC-42 (#91) is what will write
 84        // SubjectRequested.
 685        var deletedAt = DateTime.UtcNow;
 86
 687        person.IsDeleted = true;
 688        person.DeletedAt = deletedAt;
 689        person.DeletionKind = (int)DeletionKinds.Administrative;
 690        person.UpdatedAt = deletedAt;
 91
 692        var update = await personWriter.UpdateAsync(person);
 93
 694        if (!update.Success)
 95        {
 096            return output.WithErrors(update.Errors);
 97        }
 98
 99        // UC-09 step 4.
 6100        return output
 6101            .WithData(new DeletePersonCommandOutput { Id = person.PublicId, AlreadyDeleted = false })
 6102            .WithMessage(PersonMessages.PersonDeletedSuccessfully);
 20103    }
 104
 105    /// <summary>
 106    ///     UC-09 step 2. A System Admin may delete any person; a Scope Admin may delete only a
 107    ///     <c>User</c> belonging to a scope they own. Everything else is denied.
 108    /// </summary>
 109    private async Task<bool> MayDeleteAsync(DeletePersonCommand command, Person person)
 110    {
 16111        if (command.ActingRole == (int)Roles.SystemAdmin)
 112        {
 10113            return true;
 114        }
 115
 6116        if (command.ActingRole != (int)Roles.ScopeAdmin ||
 6117            person.RoleId != (long)Roles.User ||
 6118            person.ScopeMembership is null)
 119        {
 2120            return false;
 121        }
 122
 4123        return await scopeOwnership.ActorMayManageScopeAsync(
 4124            command.ActingRole, command.ActingPersonId, person.ScopeMembership.ScopeId);
 16125    }
 126
 127    /// <summary>
 128    ///     NFR-12. Gathers the scopes somebody *other* than this person owns and reports whether any
 129    ///     scope this person owns is missing from them — the same guard
 130    ///     <see cref="UpdatePersonCommandHandler" /> applies to its role change. Persons already
 131    ///     logically deleted are excluded, since they no longer keep a scope owned.
 132    /// </summary>
 133    private async Task<bool> WouldStripLastOwnerAsync(Person person)
 134    {
 135        // Whatever the role: a SystemAdmin may own a scope too (FR-SC-08).
 9136        if (person.ScopeOwnerships.Count == 0)
 137        {
 4138            return false;
 139        }
 140
 10141        var ownedScopeIds = person.ScopeOwnerships.Select(ownership => ownership.ScopeId).ToList();
 142
 5143        var coOwnedScopeIds = await personReader.Query()
 5144            .Where(other => other.Id != person.Id && !other.IsDeleted)
 5145            .SelectMany(other => other.ScopeOwnerships.Select(ownership => ownership.ScopeId))
 5146            .Distinct()
 5147            .ToListAsync();
 148
 10149        return ownedScopeIds.Any(scopeId => !coOwnedScopeIds.Contains(scopeId));
 9150    }
 151}