< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.ReapplyErasuresCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/ReapplyErasuresCommandHandler.cs
Line coverage
92%
Covered lines: 74
Uncovered lines: 6
Coverable lines: 80
Total lines: 182
Line coverage: 92.5%
Branch coverage
77%
Covered branches: 17
Total branches: 22
Branch coverage: 77.2%
Method coverage
100%
Covered methods: 2
Fully covered methods: 1
Total methods: 2
Method coverage: 100%
Full method coverage: 50%

Metrics

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

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/ReapplyErasuresCommandHandler.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.Mediator.Command.Interfaces;
 10using ArturRios.Output;
 11using Microsoft.EntityFrameworkCore;
 12
 13namespace ArturRios.Heimdall.Command.Handlers;
 14
 15/// <summary>
 16///     Handles <see cref="ReapplyErasuresCommand" /> (NFR-25): anonymises identities named by the
 17///     erasure ledger, for use after a database restore. All failures are returned as errors on the
 18///     <see cref="DataOutput{T}" /> rather than thrown.
 19/// </summary>
 20/// <remarks>
 21///     <para>
 22///         <b>It anonymises immediately, not on a deadline.</b> Every identity named here was
 23///         already erased once; the deadline that governed it has passed, and starting a fresh
 24///         window would hand back time on an obligation that was discharged before the restore.
 25///     </para>
 26///     <para>
 27///         <b>An unknown identifier is not an error.</b> A ledger covers every erasure ever
 28///         completed, while a restore reinstates only what one backup held, so most of the list will
 29///         match nothing on any given run. Treating that as failure would make the reconciliation
 30///         look broken every time it worked.
 31///     </para>
 32///     <para>
 33///         <b>It is deliberately usable more than once.</b> A restore is a stressful operation and
 34///         the runbook step may be repeated or half-completed; running it twice anonymises nothing
 35///         extra and reports the difference.
 36///     </para>
 37/// </remarks>
 938public class ReapplyErasuresCommandHandler(
 939    IAsyncReadOnlyRepository<Person, long> personReader,
 940    IAsyncRepository<Person, long> personWriter,
 941    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 942    IAsyncRepository<GoogleUser, long> googleUserWriter,
 943    IAsyncRepository<PasswordResetToken, long> passwordResetTokenWriter,
 944    IAsyncRepository<EmailVerificationToken, long> emailVerificationTokenWriter,
 945    IAsyncRepository<TwoFactorAuth, long> twoFactorAuthWriter,
 946    IAtomicWrites atomicWrites)
 47    : ICommandHandlerAsync<ReapplyErasuresCommand, ReapplyErasuresCommandOutput>
 48{
 49    public async Task<DataOutput<ReapplyErasuresCommandOutput?>> HandleAsync(ReapplyErasuresCommand command, Cancellatio
 50    {
 1051        var output = DataOutput<ReapplyErasuresCommandOutput?>.New;
 1052        var requested = command.SubjectIds.Distinct().ToList();
 53
 1054        if (requested.Count == 0)
 55        {
 156            return output.WithError(ErasureMessages.NoSubjectsToReapply);
 57        }
 58
 959        var now = DateTime.UtcNow;
 960        var anonymised = 0;
 961        var alreadyAnonymised = 0;
 62
 963        var persons = await personReader.Query()
 964            .Where(person => requested.Contains(person.PublicId))
 965            .ToListAsync();
 66
 967        var googleUsers = await googleUserReader.Query()
 968            .Where(googleUser => requested.Contains(googleUser.PublicId))
 969            .ToListAsync();
 70
 971        var toWrite = new List<Person>();
 72
 3673        foreach (var person in persons)
 74        {
 975            if (person.AnonymisedAt is not null)
 76            {
 377                alreadyAnonymised++;
 78
 379                continue;
 80            }
 81
 82            // Marked as a subject-requested deletion dated now, so the record's own state says why
 83            // it is gone and the audit trail reads correctly afterwards.
 684            person.IsDeleted = true;
 685            person.DeletedAt = person.DeletedAt ?? now;
 686            person.DeletionKind = (int)DeletionKinds.SubjectRequested;
 87
 688            IdentityAnonymiser.Anonymise(person, now);
 89
 90            // The factor budgets, as the scheduled pass clears them: by their own targeted writes.
 691            await atomicWrites.ClearSecondFactorAttemptsAsync(person);
 692            await atomicWrites.ClearReauthenticationAttemptsAsync(person);
 693            await atomicWrites.ClearAccountEmailsAsync(person);
 94
 95            // The password hash and salt likewise: a whole-row save never writes them once the row
 96            // exists, so the anonymiser's in-memory clear reaches the row only through this.
 697            await atomicWrites.ClearPasswordAsync(person);
 98
 99            // Nothing blocks it any more; it is done — as the scheduled pass leaves it.
 6100            person.ErasureBlockedReason = null;
 101
 6102            toWrite.Add(person);
 6103            anonymised++;
 6104        }
 105
 9106        var googleUsersToWrite = new List<GoogleUser>();
 107
 20108        foreach (var googleUser in googleUsers)
 109        {
 1110            if (googleUser.AnonymisedAt is not null)
 111            {
 0112                alreadyAnonymised++;
 113
 0114                continue;
 115            }
 116
 1117            googleUser.IsDeleted = true;
 1118            googleUser.DeletedAt = googleUser.DeletedAt ?? now;
 1119            googleUser.DeletionKind = (int)DeletionKinds.SubjectRequested;
 120
 1121            IdentityAnonymiser.Anonymise(googleUser, now);
 122
 1123            googleUsersToWrite.Add(googleUser);
 1124            anonymised++;
 125        }
 126
 127        // The restored copy brought back what the original erasure removed alongside the identity —
 128        // the two-factor secret with its codes, and any reset or verification tokens. Removed before
 129        // the identity is overwritten, as the scheduled pass does, so a failure here leaves the
 130        // record selectable for another attempt rather than anonymised with its secret kept.
 9131        var (_, dependentErrors) = await PersonDependentsRemoval.RemoveAsync(
 6132            toWrite.Select(person => person.Id).ToList(),
 9133            passwordResetTokenWriter,
 9134            emailVerificationTokenWriter,
 9135            twoFactorAuthWriter);
 136
 9137        if (dependentErrors.Count > 0)
 138        {
 0139            return output.WithErrors(dependentErrors);
 140        }
 141
 9142        var errors = new List<string>();
 143
 9144        if (toWrite.Count > 0)
 145        {
 6146            var update = await personWriter.UpdateRangeAsync(toWrite);
 147
 6148            if (!update.Success)
 149            {
 0150                errors.AddRange(update.Errors);
 151            }
 152        }
 153
 9154        if (googleUsersToWrite.Count > 0)
 155        {
 1156            var update = await googleUserWriter.UpdateRangeAsync(googleUsersToWrite);
 157
 1158            if (!update.Success)
 159            {
 0160                errors.AddRange(update.Errors);
 161            }
 162        }
 163
 9164        if (errors.Count > 0)
 165        {
 0166            return output.WithErrors(errors);
 167        }
 168
 18169        var found = persons.Select(person => person.PublicId)
 1170            .Concat(googleUsers.Select(googleUser => googleUser.PublicId))
 9171            .ToHashSet();
 172
 9173        return output
 9174            .WithData(new ReapplyErasuresCommandOutput
 9175            {
 9176                Anonymised = anonymised,
 9177                AlreadyAnonymised = alreadyAnonymised,
 12178                NotFound = requested.Where(id => !found.Contains(id)).ToList()
 9179            })
 9180            .WithMessage(ErasureMessages.ErasuresReapplied);
 10181    }
 182}