< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.ReapplyErasuresCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/ReapplyErasuresCommandHandler.cs
Line coverage
94%
Covered lines: 56
Uncovered lines: 3
Coverable lines: 59
Total lines: 154
Line coverage: 94.9%
Branch coverage
83%
Covered branches: 15
Total branches: 18
Branch coverage: 83.3%
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()83.33%181893.18%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/ReapplyErasuresCommandHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Command.Auditing;
 3using ArturRios.Heimdall.Command.Input;
 4using ArturRios.Heimdall.Command.Output;
 5using ArturRios.Heimdall.Command.Services;
 6using ArturRios.Heimdall.Domain.Entities;
 7using ArturRios.Heimdall.Domain.Persistence;
 8using ArturRios.Heimdall.Shared.Messages;
 9using ArturRios.Mediator.Command.Interfaces;
 10using ArturRios.Output;
 11using Microsoft.EntityFrameworkCore;
 12using Microsoft.Extensions.Logging;
 13
 14namespace ArturRios.Heimdall.Command.Handlers;
 15
 16/// <summary>
 17///     Handles <see cref="ReapplyErasuresCommand" /> (NFR-25): anonymises identities named by the
 18///     erasure ledger, for use after a database restore. All failures are returned as errors on the
 19///     <see cref="DataOutput{T}" /> rather than thrown.
 20/// </summary>
 21/// <remarks>
 22///     <para>
 23///         <b>It anonymises immediately, not on a deadline.</b> Every identity named here was
 24///         already erased once; the deadline that governed it has passed, and starting a fresh
 25///         window would hand back time on an obligation that was discharged before the restore.
 26///     </para>
 27///     <para>
 28///         <b>An unknown identifier is not an error.</b> A ledger covers every erasure ever
 29///         completed, while a restore reinstates only what one backup held, so most of the list will
 30///         match nothing on any given run. Treating that as failure would make the reconciliation
 31///         look broken every time it worked.
 32///     </para>
 33///     <para>
 34///         <b>It is deliberately usable more than once.</b> A restore is a stressful operation and
 35///         the runbook step may be repeated or half-completed; running it twice anonymises nothing
 36///         extra and reports the difference.
 37///     </para>
 38/// </remarks>
 1339public class ReapplyErasuresCommandHandler(
 1340    IAsyncReadOnlyRepository<Person, long> personReader,
 1341    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 1342    IAsyncRepository<PasswordResetToken, long> passwordResetTokenWriter,
 1343    IAsyncRepository<EmailVerificationToken, long> emailVerificationTokenWriter,
 1344    IAsyncRepository<TwoFactorAuth, long> twoFactorAuthWriter,
 1345    IAtomicWrites atomicWrites,
 1346    IScopeOwnerInvitations invitations,
 1347    IAuditLogWriter auditLog,
 1348    ILogger<ReapplyErasuresCommandHandler> logger)
 49    : ICommandHandlerAsync<ReapplyErasuresCommand, ReapplyErasuresCommandOutput>
 50{
 51    public async Task<DataOutput<ReapplyErasuresCommandOutput?>> HandleAsync(ReapplyErasuresCommand command, Cancellatio
 52    {
 1453        var output = DataOutput<ReapplyErasuresCommandOutput?>.New;
 1454        var requested = command.SubjectIds.Distinct().ToList();
 55
 1456        if (requested.Count == 0)
 57        {
 158            return output.WithError(ErasureMessages.NoSubjectsToReapply);
 59        }
 60
 1361        var now = DateTime.UtcNow;
 1362        var anonymised = 0;
 1363        var alreadyAnonymised = 0;
 64
 1365        var persons = await personReader.Query()
 1366            .Where(person => requested.Contains(person.PublicId))
 1367            .ToListAsync();
 68
 1369        var googleUsers = await googleUserReader.Query()
 1370            .Where(googleUser => requested.Contains(googleUser.PublicId))
 1371            .ToListAsync();
 72
 1373        var toWrite = new List<Person>();
 74
 5275        foreach (var person in persons)
 76        {
 1377            if (person.AnonymisedAt is not null)
 78            {
 479                alreadyAnonymised++;
 80
 481                continue;
 82            }
 83
 984            toWrite.Add(person);
 85        }
 86
 87        // The restored copy brought back what the original erasure removed alongside the identity —
 88        // the two-factor secret with its codes, and any reset or verification tokens. Removed before
 89        // the identity is overwritten, as the scheduled pass does, so a failure here leaves the
 90        // record selectable for another attempt rather than anonymised with its secret kept.
 1391        var (_, dependentErrors) = await PersonDependentsRemoval.RemoveAsync(
 992            toWrite.Select(person => person.Id).ToList(),
 1393            passwordResetTokenWriter,
 1394            emailVerificationTokenWriter,
 1395            twoFactorAuthWriter);
 96
 1397        if (dependentErrors.Count > 0)
 98        {
 099            return output.WithErrors(dependentErrors);
 100        }
 101
 102        // As the scheduled pass does: the restored copy may have brought back invitations the erasure
 103        // voided, and the person as the inviter of others (FR-SC-17). Before the identity is written,
 104        // like the dependents, so a failure leaves the person selectable for another attempt; every
 105        // person here ends anonymised, by this run or by one that got there first. Each void is audited
 106        // as it is made, so a later failure cannot lose its entry.
 13107        var voided = await ErasedPersonInvitations.ForgetAsync(
 22108            invitations, auditLog, logger, toWrite.Select(person => person.Id).ToList(), now);
 109
 110        // Then each person, by one targeted write (#237): marked as a subject-requested deletion, so
 111        // the record's own state says why it is gone and the audit trail reads correctly afterwards,
 112        // and anonymised with its password and budgets in the same statement. One anonymised since it
 113        // was read is counted as such.
 43114        foreach (var person in toWrite)
 115        {
 9116            if (await IdentityAnonymiser.ReapplyAsync(atomicWrites, person, now))
 117            {
 8118                anonymised++;
 119            }
 120            else
 121            {
 0122                alreadyAnonymised++;
 123            }
 124        }
 125
 126        // Then each Google User, by one targeted write (#205), as each person: marked as a subject-requested
 127        // deletion and anonymised in the same statement. One anonymised since it was read is counted as such.
 26128        foreach (var googleUser in googleUsers)
 129        {
 1130            if (googleUser.AnonymisedAt is null && await IdentityAnonymiser.ReapplyAsync(atomicWrites, googleUser, now))
 131            {
 1132                anonymised++;
 133            }
 134            else
 135            {
 0136                alreadyAnonymised++;
 137            }
 138        }
 139
 24140        var found = persons.Select(person => person.PublicId)
 1141            .Concat(googleUsers.Select(googleUser => googleUser.PublicId))
 12142            .ToHashSet();
 143
 12144        return output
 12145            .WithData(new ReapplyErasuresCommandOutput
 12146            {
 12147                Anonymised = anonymised,
 12148                AlreadyAnonymised = alreadyAnonymised,
 15149                NotFound = requested.Where(id => !found.Contains(id)).ToList(),
 12150                ScopeOwnerInvitationsVoided = voided.Count
 12151            })
 12152            .WithMessage(ErasureMessages.ErasuresReapplied);
 13153    }
 154}