< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.HardDeletePersonCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/HardDeletePersonCommandHandler.cs
Line coverage
97%
Covered lines: 69
Uncovered lines: 2
Coverable lines: 71
Total lines: 176
Line coverage: 97.1%
Branch coverage
81%
Covered branches: 13
Total branches: 16
Branch coverage: 81.2%
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()80%101095.65%
WouldStripLastOwnerAsync()100%22100%
DeleteAllAsync()75%44100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/HardDeletePersonCommandHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Entities;
 2using ArturRios.Data.Relational.Core.Interfaces;
 3using ArturRios.Heimdall.Command.Input;
 4using ArturRios.Heimdall.Command.Output;
 5using ArturRios.Heimdall.Domain.Entities;
 6using ArturRios.Heimdall.Domain.Enums;
 7using ArturRios.Heimdall.Domain.Persistence;
 8using ArturRios.Heimdall.Shared.Messages;
 9using ArturRios.Mediator.Command.Interfaces;
 10using ArturRios.Output;
 11using Microsoft.EntityFrameworkCore;
 12
 13namespace ArturRios.Heimdall.Command.Handlers;
 14
 15/// <summary>
 16///     Handles <see cref="HardDeletePersonCommand" /> (UC-10): locates the person in any deletion state
 17///     (AF-10a), refuses a self-deletion (AF-10c), refuses to strip a scope of its last owner (AF-10b,
 18///     NFR-12), then permanently deletes the applications the person owns (NFR-11) and their password
 19///     reset and email verification tokens, and finally the person — whose <c>ON DELETE CASCADE</c>
 20///     foreign keys remove the <c>SCOPE_USER</c>/<c>SCOPE_OWNER</c> join rows. The response reports the
 21///     totals of the removed dependents, counted regardless of their individual deletion state. All
 22///     failures are returned as errors on the <see cref="DataOutput{T}" /> rather than thrown.
 23/// </summary>
 24/// <remarks>
 25///     Before anything is removed, the person's <c>PublicId</c> is tombstoned
 26///     (<see cref="ErasedSubject" />, NFR-21, #195). Audit entries name their target by that bare id,
 27///     so once the row is gone the tombstone is the only evidence the retention pass and the database
 28///     have that those entries — this hard delete's own among them — name a person whose target must be
 29///     cleared. Written first, a failure later leaves a tombstone for a live row, which nothing acts
 30///     on; and a retry writes it again without error.
 31/// </remarks>
 1932public class HardDeletePersonCommandHandler(
 1933    IAsyncReadOnlyRepository<Person, long> personReader,
 1934    IAsyncRepository<Person, long> personWriter,
 1935    IAsyncReadOnlyRepository<Application, long> applicationReader,
 1936    IAsyncRepository<Application, long> applicationWriter,
 1937    IAsyncReadOnlyRepository<PasswordResetToken, long> passwordResetTokenReader,
 1938    IAsyncRepository<PasswordResetToken, long> passwordResetTokenWriter,
 1939    IAsyncReadOnlyRepository<EmailVerificationToken, long> emailVerificationTokenReader,
 1940    IAsyncRepository<EmailVerificationToken, long> emailVerificationTokenWriter,
 1941    IErasedSubjects erasedSubjects)
 42    : ICommandHandlerAsync<HardDeletePersonCommand, HardDeletePersonCommandOutput>
 43{
 44    public async Task<DataOutput<HardDeletePersonCommandOutput?>> HandleAsync(HardDeletePersonCommand command, Cancellat
 45    {
 1946        var output = DataOutput<HardDeletePersonCommandOutput?>.New;
 47
 48        // AF-10a: the lookup omits an !IsDeleted filter — a logically deleted person is exactly what a
 49        // cleanup pass starts from, so it must still be hard-deletable. ScopeOwnerships is needed by
 50        // the last-owner guard below.
 1951        var person = await personReader.Query()
 1952            .Include(x => x.ScopeOwnerships)
 1953            .FirstOrDefaultAsync(x => x.PublicId == command.Id);
 54
 1955        if (person is null)
 56        {
 257            return output.WithError(PersonMessages.PersonNotFound);
 58        }
 59
 60        // AF-10c: nobody hard-deletes their own record, System Admin included, so one call cannot
 61        // permanently destroy the caller's own account. Checked before the last-owner guard, so a
 62        // caller targeting themselves gets the reason that applies to them.
 1763        if (command.ActingPersonId == person.PublicId)
 64        {
 265            return output.WithError(PersonMessages.CannotDeleteSelf);
 66        }
 67
 68        // UC-10 step 3 (AF-10b, NFR-12). Unlike UC-09, this runs regardless of the person's own
 69        // deletion state: NFR-12 names hard-deleting the last owning person explicitly, and the guard
 70        // keeps every scope row backed by at least one SCOPE_OWNER row.
 1571        if (await WouldStripLastOwnerAsync(person))
 72        {
 473            return output.WithError(PersonMessages.ScopeWouldLoseLastOwner);
 74        }
 75
 76        // UC-10 step 4 (NFR-21, #195): tombstone the id before anything irreversible happens, so the
 77        // trail's entries about this person can still be cleared once the row that said who they
 78        // were is gone.
 1179        await erasedSubjects.RecordAsync(
 1180        [
 1181            new ErasedSubject
 1182            {
 1183                Id = person.PublicId,
 1184                Kind = (int)ErasedSubjectKinds.Person,
 1185                CreatedAt = DateTime.UtcNow
 1186            }
 1187        ]);
 88
 89        // UC-10 steps 5-6: the dependents, counted regardless of individual deletion state.
 1190        var applications = await applicationReader.Query()
 1191            .Where(a => a.OwnerId == person.Id)
 1192            .ToListAsync();
 1193        var passwordResetTokens = await passwordResetTokenReader.Query()
 1194            .Where(t => t.PersonId == person.Id)
 1195            .ToListAsync();
 1196        var emailVerificationTokens = await emailVerificationTokenReader.Query()
 1197            .Where(t => t.PersonId == person.Id)
 1198            .ToListAsync();
 99
 100        // Applications and tokens reference the person, so they go first and no foreign key is ever
 101        // violated.
 11102        var deleteErrors = (await DeleteAllAsync(applications, applicationWriter))
 11103            .Concat(await DeleteAllAsync(passwordResetTokens, passwordResetTokenWriter))
 11104            .Concat(await DeleteAllAsync(emailVerificationTokens, emailVerificationTokenWriter))
 11105            .ToList();
 106
 11107        if (deleteErrors.Count > 0)
 108        {
 0109            return output.WithErrors(deleteErrors);
 110        }
 111
 112        // UC-10 steps 7-8: delete the person; its ON DELETE CASCADE foreign keys clear the SCOPE_USER
 113        // or SCOPE_OWNER join rows.
 11114        var personDelete = await personWriter.DeleteAsync(person);
 115
 11116        if (!personDelete.Success)
 117        {
 0118            return output.WithErrors(personDelete.Errors);
 119        }
 120
 121        // UC-10 step 9.
 11122        return output
 11123            .WithData(new HardDeletePersonCommandOutput
 11124            {
 11125                Id = person.PublicId,
 11126                DeletedApplicationCount = applications.Count,
 11127                DeletedTokenCount = passwordResetTokens.Count + emailVerificationTokens.Count
 11128            })
 11129            .WithMessage(PersonMessages.PersonHardDeletedSuccessfully);
 19130    }
 131
 132    /// <summary>
 133    ///     NFR-12. Gathers the scopes somebody *other* than this person owns and reports whether any
 134    ///     scope this person owns is missing from them — the same guard
 135    ///     <see cref="DeletePersonCommandHandler" /> and <see cref="UpdatePersonCommandHandler" />
 136    ///     apply. Persons already logically deleted are excluded, since they no longer keep a scope
 137    ///     owned.
 138    /// </summary>
 139    private async Task<bool> WouldStripLastOwnerAsync(Person person)
 140    {
 141        // Whatever the role: a SystemAdmin may own a scope too (FR-SC-08).
 15142        if (person.ScopeOwnerships.Count == 0)
 143        {
 9144            return false;
 145        }
 146
 12147        var ownedScopeIds = person.ScopeOwnerships.Select(ownership => ownership.ScopeId).ToList();
 148
 6149        var coOwnedScopeIds = await personReader.Query()
 6150            .Where(other => other.Id != person.Id && !other.IsDeleted)
 6151            .SelectMany(other => other.ScopeOwnerships.Select(ownership => ownership.ScopeId))
 6152            .Distinct()
 6153            .ToListAsync();
 154
 12155        return ownedScopeIds.Any(scopeId => !coOwnedScopeIds.Contains(scopeId));
 15156    }
 157
 158    /// <summary>
 159    ///     Permanently removes every entity in <paramref name="dependents" /> by internal Id, or does
 160    ///     nothing when the set is empty. Returns any persistence errors, or an empty sequence on
 161    ///     success / no-op.
 162    /// </summary>
 163    private static async Task<IEnumerable<string>> DeleteAllAsync<T>(
 164        IReadOnlyCollection<T> dependents,
 165        IAsyncRepository<T, long> writer) where T : Entity<long>
 166    {
 33167        if (dependents.Count == 0)
 168        {
 22169            return [];
 170        }
 171
 22172        var result = await writer.DeleteRangeAsync(dependents.Select(dependent => dependent.Id));
 173
 11174        return result.Success ? [] : result.Errors;
 33175    }
 176}