< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.HardDeletePersonCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/HardDeletePersonCommandHandler.cs
Line coverage
97%
Covered lines: 67
Uncovered lines: 2
Coverable lines: 69
Total lines: 188
Line coverage: 97.1%
Branch coverage
85%
Covered branches: 17
Total branches: 20
Branch coverage: 85%
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()81.25%161694.23%
DeleteAllAsync()75%44100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/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.Command.Services;
 6using ArturRios.Heimdall.Domain.Entities;
 7using ArturRios.Heimdall.Domain.Enums;
 8using ArturRios.Heimdall.Domain.Persistence;
 9using ArturRios.Heimdall.Shared.Messages;
 10using ArturRios.Mediator.Command.Interfaces;
 11using ArturRios.Output;
 12using Microsoft.EntityFrameworkCore;
 13
 14namespace ArturRios.Heimdall.Command.Handlers;
 15
 16/// <summary>
 17///     Handles <see cref="HardDeletePersonCommand" /> (UC-10): locates the person in any deletion state
 18///     (AF-10a), refuses a self-deletion (AF-10c), refuses to strip a scope of its last owner (AF-10b,
 19///     NFR-12) or the system of its last active System Admin (AF-10d, NFR-32), then permanently deletes
 20///     the applications the person owns (NFR-11) and their password reset and email verification
 21///     tokens, and finally the person — whose <c>ON DELETE CASCADE</c>
 22///     foreign keys remove the <c>SCOPE_USER</c>/<c>SCOPE_OWNER</c> join rows. The response reports the
 23///     totals of the removed dependents, counted regardless of their individual deletion state. All
 24///     failures are returned as errors on the <see cref="DataOutput{T}" /> rather than thrown.
 25/// </summary>
 26/// <remarks>
 27///     <para>
 28///         Before anything is removed, the person's <c>PublicId</c> is tombstoned
 29///         (<see cref="ErasedSubject" />, NFR-21, #195). Audit entries name their target by that bare id,
 30///         so once the row is gone the tombstone is the only evidence the retention pass and the database
 31///         have that those entries — this hard delete's own among them — name a person whose target must
 32///         be cleared. A retry writes it again without error.
 33///     </para>
 34///     <para>
 35///         <b>All or nothing</b> (#212). The tombstone, the dependents and the person are removed in one
 36///         <see cref="IWriteTransactions" /> transaction, committed only once the person is gone, so a
 37///         failure at any step leaves the person with every application and token they had, and no
 38///         tombstone for a live row.
 39///     </para>
 40///     <para>
 41///         <b>Under the locks of the person's scopes and of the System Admin set</b> (#202, #203). The
 42///         transaction opens before the guards and first locks the rows of the scopes the person owns, then
 43///         the System Admin set (<see cref="IInvariantLocks" />), and reads the person again under them
 44///         (<see cref="LockedPerson" />), as every writer that can take an owner or a System Admin out does,
 45///         so the guards' answers still hold when the person is deleted.
 46///     </para>
 47/// </remarks>
 2748public class HardDeletePersonCommandHandler(
 2749    IAsyncReadOnlyRepository<Person, long> personReader,
 2750    IAsyncRepository<Person, long> personWriter,
 2751    IAsyncReadOnlyRepository<Application, long> applicationReader,
 2752    IAsyncRepository<Application, long> applicationWriter,
 2753    IAsyncReadOnlyRepository<PasswordResetToken, long> passwordResetTokenReader,
 2754    IAsyncRepository<PasswordResetToken, long> passwordResetTokenWriter,
 2755    IAsyncReadOnlyRepository<EmailVerificationToken, long> emailVerificationTokenReader,
 2756    IAsyncRepository<EmailVerificationToken, long> emailVerificationTokenWriter,
 2757    IErasedSubjects erasedSubjects,
 2758    IWriteTransactions transactions,
 2759    IInvariantLocks locks)
 60    : ICommandHandlerAsync<HardDeletePersonCommand, HardDeletePersonCommandOutput>
 61{
 62    public async Task<DataOutput<HardDeletePersonCommandOutput?>> HandleAsync(HardDeletePersonCommand command, Cancellat
 63    {
 2764        var output = DataOutput<HardDeletePersonCommandOutput?>.New;
 65
 66        // AF-10a: the lookup omits an !IsDeleted filter — a logically deleted person is exactly what a
 67        // cleanup pass starts from, so it must still be hard-deletable.
 2768        var person = await personReader.Query()
 2769            .FirstOrDefaultAsync(x => x.PublicId == command.Id);
 70
 2771        if (person is null)
 72        {
 273            return output.WithError(PersonMessages.PersonNotFound);
 74        }
 75
 76        // AF-10c: nobody hard-deletes their own record, System Admin included, so one call cannot
 77        // permanently destroy the caller's own account. Checked before the last-owner guard, so a
 78        // caller targeting themselves gets the reason that applies to them.
 2579        if (command.ActingPersonId == person.PublicId)
 80        {
 381            return output.WithError(PersonMessages.CannotDeleteSelf);
 82        }
 83
 84        // UC-10 steps 3-8 land together or not at all (#212): leaving this block by any return or
 85        // exception before the commit rolls every one of them back.
 2286        await using var transaction = await transactions.BeginAsync();
 87
 88        // The person's scopes and the System Admin set, locked first, and the person read again under them
 89        // (#202, #203), so the guards below decide on what is still true when the person is deleted.
 2290        var locked = await LockedPerson.LockAsync(locks, personReader, person.Id);
 91
 2292        if (locked is null)
 93        {
 094            return output.WithError(PersonMessages.PersonNotFound);
 95        }
 96
 97        // UC-10 step 3 (AF-10b, NFR-12). Unlike UC-09, this runs regardless of the person's own
 98        // deletion state: NFR-12 names hard-deleting the last owning person explicitly, and the guard
 99        // keeps every scope row backed by at least one SCOPE_OWNER row.
 22100        if (await LastScopeOwnerGuard.WouldStripLastOwnerAsync(person.Id, locked.OwnedScopeIds, personReader))
 101        {
 7102            return output.WithError(PersonMessages.ScopeWouldLoseLastOwner);
 103        }
 104
 105        // AF-10d (NFR-32): nor may it leave the system without an active System Admin. Only a live one is
 106        // guarded: a logically deleted administrator is no longer one of the set.
 15107        if (locked.IsLiveSystemAdmin && await LastSystemAdminGuard.WouldLeaveNoneAsync(person.Id, personReader))
 108        {
 1109            return output.WithError(PersonMessages.WouldRemoveLastSystemAdmin);
 110        }
 111
 112        // UC-10 step 4 (NFR-21, #195): tombstone the id before anything irreversible happens, so the
 113        // trail's entries about this person can still be cleared once the row that said who they
 114        // were is gone.
 14115        await erasedSubjects.RecordAsync(
 14116        [
 14117            new ErasedSubject
 14118            {
 14119                Id = person.PublicId,
 14120                Kind = (int)ErasedSubjectKinds.Person,
 14121                CreatedAt = DateTime.UtcNow
 14122            }
 14123        ]);
 124
 125        // UC-10 steps 5-6: the dependents, counted regardless of individual deletion state.
 14126        var applications = await applicationReader.Query()
 14127            .Where(a => a.OwnerId == person.Id)
 14128            .ToListAsync();
 14129        var passwordResetTokens = await passwordResetTokenReader.Query()
 14130            .Where(t => t.PersonId == person.Id)
 14131            .ToListAsync();
 14132        var emailVerificationTokens = await emailVerificationTokenReader.Query()
 14133            .Where(t => t.PersonId == person.Id)
 14134            .ToListAsync();
 135
 136        // Applications and tokens reference the person, so they go first and no foreign key is ever
 137        // violated.
 14138        var deleteErrors = (await DeleteAllAsync(applications, applicationWriter))
 14139            .Concat(await DeleteAllAsync(passwordResetTokens, passwordResetTokenWriter))
 14140            .Concat(await DeleteAllAsync(emailVerificationTokens, emailVerificationTokenWriter))
 14141            .ToList();
 142
 14143        if (deleteErrors.Count > 0)
 144        {
 0145            return output.WithErrors(deleteErrors);
 146        }
 147
 148        // UC-10 steps 7-8: delete the person; its ON DELETE CASCADE foreign keys clear the SCOPE_USER
 149        // or SCOPE_OWNER join rows.
 14150        var personDelete = await personWriter.DeleteAsync(person);
 151
 14152        if (!personDelete.Success)
 153        {
 1154            return output.WithErrors(personDelete.Errors);
 155        }
 156
 13157        await transaction.CommitAsync();
 158
 159        // UC-10 step 9.
 13160        return output
 13161            .WithData(new HardDeletePersonCommandOutput
 13162            {
 13163                Id = person.PublicId,
 13164                DeletedApplicationCount = applications.Count,
 13165                DeletedTokenCount = passwordResetTokens.Count + emailVerificationTokens.Count
 13166            })
 13167            .WithMessage(PersonMessages.PersonHardDeletedSuccessfully);
 27168    }
 169
 170    /// <summary>
 171    ///     Permanently removes every entity in <paramref name="dependents" /> by internal Id, or does
 172    ///     nothing when the set is empty. Returns any persistence errors, or an empty sequence on
 173    ///     success / no-op.
 174    /// </summary>
 175    private static async Task<IEnumerable<string>> DeleteAllAsync<T>(
 176        IReadOnlyCollection<T> dependents,
 177        IAsyncRepository<T, long> writer) where T : Entity<long>
 178    {
 42179        if (dependents.Count == 0)
 180        {
 28181            return [];
 182        }
 183
 28184        var result = await writer.DeleteRangeAsync(dependents.Select(dependent => dependent.Id));
 185
 14186        return result.Success ? [] : result.Errors;
 42187    }
 188}