< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.ExportMyDataCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/ExportMyDataCommandHandler.cs
Line coverage
98%
Covered lines: 180
Uncovered lines: 3
Coverable lines: 183
Total lines: 268
Line coverage: 98.3%
Branch coverage
64%
Covered branches: 18
Total branches: 28
Branch coverage: 64.2%
Method coverage
85%
Covered methods: 6
Fully covered methods: 5
Total methods: 7
Method coverage: 85.7%
Full method coverage: 71.4%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()100%44100%
ExportPersonAsync()71.42%141498.78%
Public()0%620%
ExportGoogleUserAsync()50%22100%
AuditEntriesAsync()100%22100%
Processing(...)50%22100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/ExportMyDataCommandHandler.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.Shared.Messages;
 8using ArturRios.Mediator.Command.Interfaces;
 9using ArturRios.Output;
 10using Microsoft.EntityFrameworkCore;
 11using Application = ArturRios.Heimdall.Domain.Entities.Application;
 12
 13namespace ArturRios.Heimdall.Command.Handlers;
 14
 15/// <summary>
 16///     Handles <see cref="ExportMyDataCommand" /> (UC-41): assembles everything held about the
 17///     authenticated caller into one document. All failures are returned as errors on the
 18///     <see cref="DataOutput{T}" /> rather than thrown.
 19/// </summary>
 20/// <remarks>
 21///     <para>
 22///         <b>The subject is the token's, never the request's.</b> Same shape as UC-42: no path
 23///         parameter and no body field names an identity, so there is no way to ask for somebody
 24///         else's data. The rule is enforced by the absence of the parameter rather than by a check.
 25///     </para>
 26///     <para>
 27///         <b>A deleted identity can still export.</b> The lookups omit the <c>!IsDeleted</c> filter
 28///         that most handlers apply. Somebody suspended pending erasure has more reason to want a
 29///         copy than anyone, and Art. 15 is not conditional on the account being in good standing.
 30///         An anonymised record is a different matter — there is nothing left that relates to a
 31///         person — but it also cannot authenticate, so it never reaches here.
 32///     </para>
 33///     <para>
 34///         <b>What is deliberately absent.</b> Audit entries whose attribution has been cleared
 35///         under NFR-21 do not appear — those the subject made, or those made about them, and cannot: once cleared, not
 36///         person. Saying so on the document would itself be a re-identification, so the export
 37///         reports what it can still find and the retention section explains why older entries stop
 38///         being findable.
 39///     </para>
 40/// </remarks>
 2541public class ExportMyDataCommandHandler(
 2542    IAsyncReadOnlyRepository<Person, long> personReader,
 2543    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 2544    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 2545    IAsyncReadOnlyRepository<Application, long> applicationReader,
 2546    IAsyncReadOnlyRepository<TwoFactorAuth, long> twoFactorReader,
 2547    IAsyncReadOnlyRepository<TwoFactorRecoveryCode, long> recoveryCodeReader,
 2548    IAsyncReadOnlyRepository<AuditLog, long> auditReader)
 49    : ICommandHandlerAsync<ExportMyDataCommand, DataExportCommandOutput>
 50{
 51    public async Task<DataOutput<DataExportCommandOutput?>> HandleAsync(ExportMyDataCommand command, CancellationToken c
 52    {
 2553        var output = DataOutput<DataExportCommandOutput?>.New;
 54
 2555        var person = await personReader.Query()
 2556            .Include(x => x.ScopeMembership)
 2557            .Include(x => x.ScopeOwnerships)
 2558            .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId);
 59
 2560        if (person is not null)
 61        {
 2162            return output
 2163                .WithData(await ExportPersonAsync(person))
 2164                .WithMessage(ErasureMessages.DataExported);
 65        }
 66
 467        var googleUser = await googleUserReader.Query()
 468            .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId);
 69
 470        return googleUser is not null
 471            ? output.WithData(await ExportGoogleUserAsync(googleUser)).WithMessage(ErasureMessages.DataExported)
 472            : output.WithError(ErasureMessages.NotEligible);
 2573    }
 74
 75    private async Task<DataExportCommandOutput> ExportPersonAsync(Person person)
 76    {
 2177        var twoFactor = await twoFactorReader.Query()
 2178            .FirstOrDefaultAsync(x => x.PersonId == person.Id);
 79
 2180        var unusedRecoveryCodes = twoFactor is null
 2181            ? 0
 2182            : await recoveryCodeReader.Query()
 2183                .CountAsync(x => x.TwoFactorAuthId == twoFactor.Id && !x.Used);
 84
 2185        var applications = await applicationReader.Query()
 2186            .Where(x => x.OwnerId == person.Id)
 2187            .Select(x => new DataExportApplication
 2188            {
 2189                Id = x.PublicId,
 2190                Name = x.Name,
 2191                ScopeId = x.Scope.PublicId,
 2192                IsDeleted = x.IsDeleted
 2193            })
 2194            .ToListAsync();
 95
 96        // Internal scope ids are translated to public ones: NFR-15 applies to an export as much as
 97        // to any other response.
 2198        var scopeIds = person.ScopeOwnerships.Select(ownership => ownership.ScopeId).ToList();
 99
 21100        if (person.ScopeMembership is not null)
 101        {
 0102            scopeIds.Add(person.ScopeMembership.ScopeId);
 103        }
 104
 21105        var publicScopeIds = await scopeReader.Query()
 21106            .Where(scope => scopeIds.Contains(scope.Id))
 21107            .Select(scope => new { scope.Id, scope.PublicId })
 21108            .ToListAsync();
 109
 110        Guid? Public(long internalId) =>
 0111            publicScopeIds.FirstOrDefault(scope => scope.Id == internalId)?.PublicId;
 112
 21113        var (auditEntries, truncated) = await AuditEntriesAsync(person.PublicId);
 114
 21115        return new DataExportCommandOutput
 21116        {
 21117            ExportedAt = DateTime.UtcNow,
 21118            Subject = new DataExportSubject
 21119            {
 21120                Id = person.PublicId,
 21121                Kind = "Person",
 21122                Name = person.Name,
 21123                Email = person.Email,
 21124                EmailVerified = person.EmailVerified,
 21125                Role = (int)person.RoleId,
 21126                IsDeleted = person.IsDeleted,
 21127                CreatedAt = person.CreatedAt,
 21128                UpdatedAt = person.UpdatedAt
 21129            },
 21130            Security = new DataExportSecurity
 21131            {
 21132                PasswordSet = person.PasswordHash.Length > 0,
 21133                TwoFactorActive = twoFactor?.IsActive ?? false,
 21134                TwoFactorAppEnabled = twoFactor?.AppEnabled ?? false,
 21135                TwoFactorEmailEnabled = twoFactor?.EmailEnabled ?? false,
 21136                UnusedRecoveryCodes = unusedRecoveryCodes,
 21137                FailedLoginAttempts = person.FailedLoginAttempts,
 21138                LockedOutUntil = person.LockedOutUntil,
 21139                FailedSecondFactorAttempts = person.FailedSecondFactorAttempts,
 21140                SecondFactorLockedOutUntil = person.SecondFactorLockedOutUntil,
 21141                SecondFactorLockouts = person.SecondFactorLockouts,
 21142                FailedReauthenticationAttempts = person.FailedReauthenticationAttempts,
 21143                ReauthenticationWindowStartedAt = person.ReauthenticationWindowStartedAt,
 21144                ReauthenticationPausedUntil = person.ReauthenticationPausedUntil,
 21145                AccountEmailsSent = person.AccountEmailsSent,
 21146                AccountEmailWindowEndsAt = person.AccountEmailWindowEndsAt,
 21147                AccountEmailLastSentAt = person.AccountEmailLastSentAt
 21148            },
 21149            Processing = Processing(person.LegalBasis, person.PrivacyNoticeVersion, person.BasisRecordedAt),
 21150            Erasure = new DataExportErasure
 21151            {
 21152                IsDeleted = person.IsDeleted,
 21153                DeletedAt = person.DeletedAt,
 21154                DeletionKind = person.DeletionKind,
 21155                AnonymisedAt = person.AnonymisedAt,
 21156                ErasureRequestedAt = person.ErasureRequestedAt,
 21157                ErasureDueAt = person.ErasureDueAt,
 21158                ErasureBlockedReason = person.ErasureBlockedReason
 21159            },
 21160            ScopeMemberships = person.ScopeMembership is null
 21161                ? []
 21162                : [Public(person.ScopeMembership.ScopeId) ?? Guid.Empty],
 21163            ScopeOwnerships = person.ScopeOwnerships
 0164                .Select(ownership => Public(ownership.ScopeId) ?? Guid.Empty)
 21165                .ToList(),
 21166            Applications = applications,
 21167            AuditEntries = auditEntries,
 21168            AuditEntriesTruncated = truncated,
 21169            Withheld = DataExportDisclosure.Withheld,
 21170            Recipients = DataExportDisclosure.Recipients,
 21171            Sources = DataExportDisclosure.Sources,
 21172            Retention = DataExportDisclosure.Retention
 21173        };
 21174    }
 175
 176    private async Task<DataExportCommandOutput> ExportGoogleUserAsync(GoogleUser googleUser)
 177    {
 3178        var scope = await scopeReader.Query()
 3179            .FirstOrDefaultAsync(x => x.Id == googleUser.ScopeId);
 180
 3181        var (auditEntries, truncated) = await AuditEntriesAsync(googleUser.PublicId);
 182
 3183        return new DataExportCommandOutput
 3184        {
 3185            ExportedAt = DateTime.UtcNow,
 3186            Subject = new DataExportSubject
 3187            {
 3188                Id = googleUser.PublicId,
 3189                Kind = "GoogleUser",
 3190                Name = googleUser.Name,
 3191                Email = googleUser.Email,
 3192                EmailVerified = googleUser.EmailVerified,
 3193                // FR-GO-04 makes a Google User permanently User-equivalent, so there is no stored
 3194                // role to report — reporting one would imply a choice that does not exist.
 3195                Role = null,
 3196                GoogleId = googleUser.GoogleId,
 3197                ProfilePictureUrl = googleUser.ProfilePictureUrl,
 3198                IsDeleted = googleUser.IsDeleted,
 3199                CreatedAt = googleUser.CreatedAt,
 3200                UpdatedAt = googleUser.UpdatedAt
 3201            },
 3202            // A Google User has no password and cannot enable two-factor (FR-2F-01), so every field
 3203            // here is false by construction rather than by happening to be unset.
 3204            Security = new DataExportSecurity { PasswordSet = false },
 3205            Processing = Processing(
 3206                googleUser.LegalBasis, googleUser.PrivacyNoticeVersion, googleUser.BasisRecordedAt),
 3207            Erasure = new DataExportErasure
 3208            {
 3209                IsDeleted = googleUser.IsDeleted,
 3210                DeletedAt = googleUser.DeletedAt,
 3211                DeletionKind = googleUser.DeletionKind,
 3212                AnonymisedAt = googleUser.AnonymisedAt,
 3213                ErasureRequestedAt = googleUser.ErasureRequestedAt,
 3214                ErasureDueAt = googleUser.ErasureDueAt,
 3215                ErasureBlockedReason = googleUser.ErasureBlockedReason
 3216            },
 3217            ScopeMemberships = scope is null ? [] : [scope.PublicId],
 3218            AuditEntries = auditEntries,
 3219            AuditEntriesTruncated = truncated,
 3220            Withheld = DataExportDisclosure.Withheld,
 3221            Recipients = DataExportDisclosure.Recipients,
 3222            Sources = DataExportDisclosure.Sources,
 3223            Retention = DataExportDisclosure.Retention
 3224        };
 3225    }
 226
 227    /// <summary>
 228    ///     The entries the subject made and the entries made about them. The second kind is data about
 229    ///     the subject whoever acted (Art. 15), so it is included; <see cref="AuditLog.ActorPersonId" />
 230    ///     is left out of it, since the actor is somebody else.
 231    /// </summary>
 232    private async Task<(List<DataExportAuditEntry> Entries, bool Truncated)> AuditEntriesAsync(Guid subjectId)
 233    {
 234        // One more than the cap, so reaching it is detectable without a second count query.
 24235        var entries = await auditReader.Query()
 24236            .Where(entry => entry.ActorPersonId == subjectId || entry.TargetId == subjectId)
 24237            .OrderByDescending(entry => entry.CreatedAt)
 24238            .Take(DataExportDisclosure.MaximumAuditEntries + 1)
 24239            .Select(entry => new DataExportAuditEntry
 24240            {
 24241                Id = entry.PublicId,
 24242                Involvement = entry.ActorPersonId == subjectId
 24243                    ? DataExportAuditEntry.AsActor
 24244                    : DataExportAuditEntry.AsTarget,
 24245                Action = entry.Action,
 24246                TargetId = entry.TargetId,
 24247                Succeeded = entry.Succeeded,
 24248                FailureReason = entry.FailureReason,
 24249                CreatedAt = entry.CreatedAt
 24250            })
 24251            .ToListAsync();
 252
 24253        var truncated = entries.Count > DataExportDisclosure.MaximumAuditEntries;
 254
 24255        return (truncated ? entries.Take(DataExportDisclosure.MaximumAuditEntries).ToList() : entries, truncated);
 24256    }
 257
 258    private static DataExportProcessing Processing(int basis, string? noticeVersion, DateTime? recordedAt) =>
 24259        new()
 24260        {
 24261            LegalBasis = basis,
 24262            LegalBasisName = Enum.IsDefined((LegalBases)basis)
 24263                ? ((LegalBases)basis).ToString()
 24264                : nameof(LegalBases.Unrecorded),
 24265            PrivacyNoticeVersion = noticeVersion,
 24266            RecordedAt = recordedAt
 24267        };
 268}