< Summary

Information
Class: ProjectTemplate.Infrastructure.Data.Auditing.ApplicationAuditReconciler
Assembly: ProjectTemplate.Infrastructure
File(s): /home/runner/work/NetCoreApplicationTemplate/NetCoreApplicationTemplate/src/ProjectTemplate.Infrastructure/Data/Auditing/ApplicationAuditReconciler.cs
Line coverage
74%
Covered lines: 283
Uncovered lines: 98
Coverable lines: 381
Total lines: 579
Line coverage: 74.2%
Branch coverage
58%
Covered branches: 64
Total branches: 110
Branch coverage: 58.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)50%1212100%
ReconcileAsync()100%22100%
GetSummaryAsync()0%620%
QueryFindingsAsync()0%7280%
RecordRemediationAsync()50%66100%
BuildCandidates(...)78.12%683267.1%
AddDeliveryCandidate(...)56.25%621643.75%
PersistCandidatesAsync()83.33%6684.78%
GetSummaryCoreAsync()100%11100%
HasMalformedCorrelation(...)50%1818100%
HasMultipleValues(...)100%1183.33%
IsWhitespaceOnly(...)50%22100%
ToReceipt(...)100%11100%
Candidate(...)100%11100%
Add(...)100%11100%
NormalizeRequired(...)50%22100%
NormalizeOptional(...)75%44100%
UtcNow()100%11100%

File(s)

/home/runner/work/NetCoreApplicationTemplate/NetCoreApplicationTemplate/src/ProjectTemplate.Infrastructure/Data/Auditing/ApplicationAuditReconciler.cs

#LineLine coverage
 1using System.Security.Cryptography;
 2using System.Text;
 3using Microsoft.EntityFrameworkCore;
 4using Microsoft.Extensions.Options;
 5using ProjectTemplate.Infrastructure.Data.Entities;
 6
 7namespace ProjectTemplate.Infrastructure.Data.Auditing;
 8
 169public sealed class ApplicationAuditReconciler(
 1610    ApplicationDbContext dbContext,
 1611    IApplicationMutationManifestVerifier manifestVerifier,
 1612    IOptions<ApplicationAuditReconciliationOptions> options,
 1613    ApplicationAuditReconciliationMetrics metrics,
 1614    TimeProvider timeProvider)
 15    : IApplicationAuditReconciler
 16{
 1617    private readonly ApplicationDbContext _dbContext =
 1618        dbContext ?? throw new ArgumentNullException(nameof(dbContext));
 1619    private readonly IApplicationMutationManifestVerifier _manifestVerifier =
 1620        manifestVerifier ?? throw new ArgumentNullException(nameof(manifestVerifier));
 1621    private readonly ApplicationAuditReconciliationOptions _options =
 1622        options?.Value ?? throw new ArgumentNullException(nameof(options));
 1623    private readonly ApplicationAuditReconciliationMetrics _metrics =
 1624        metrics ?? throw new ArgumentNullException(nameof(metrics));
 1625    private readonly TimeProvider _timeProvider =
 1626        timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
 27
 28    public async Task<ApplicationAuditReconciliationSummary> ReconcileAsync(
 29        CancellationToken cancellationToken = default)
 30    {
 1831        cancellationToken.ThrowIfCancellationRequested();
 1832        if (!_options.Enabled)
 33        {
 234            return ApplicationAuditReconciliationMetrics.DisabledSummary;
 35        }
 36
 1637        DateTime now = UtcNow();
 1638        List<string> batchIds = await _dbContext.AuditRecords
 1639            .AsNoTracking()
 1640            .Where(record => record.MutationBatchId != string.Empty)
 1641            .GroupBy(record => record.MutationBatchId)
 1642            .OrderByDescending(group => group.Max(record => record.ModifiedOnUtc))
 1643            .Select(group => group.Key)
 1644            .Take(_options.MaximumBatchesPerRun)
 1645            .ToListAsync(cancellationToken)
 1646            .ConfigureAwait(false);
 47
 1648        List<AuditRecord> auditRecords = await _dbContext.AuditRecords
 1649            .AsNoTracking()
 1650            .Where(record => batchIds.Contains(record.MutationBatchId) || record.MutationBatchId == string.Empty)
 1651            .ToListAsync(cancellationToken)
 1652            .ConfigureAwait(false);
 53
 1654        List<ApplicationAuditCompletionOutboxEntry> completionEntries = await _dbContext
 1655            .ApplicationAuditCompletionOutboxEntries
 1656            .AsNoTracking()
 1657            .Where(entry => batchIds.Contains(entry.MutationBatchId) ||
 1658                entry.Status != ApplicationAuditCompletionOutboxStatuses.Delivered)
 1659            .OrderByDescending(entry => entry.CreatedUtc)
 1660            .Take(_options.MaximumBatchesPerRun * 2)
 1661            .ToListAsync(cancellationToken)
 1662            .ConfigureAwait(false);
 63
 1664        List<ApplicationAuditReconciliationCandidate> candidates = BuildCandidates(
 1665            auditRecords,
 1666            completionEntries,
 1667            now);
 68
 1669        await PersistCandidatesAsync(candidates, batchIds, completionEntries, now, cancellationToken)
 1670            .ConfigureAwait(false);
 71
 1672        ApplicationAuditReconciliationSummary summary = await GetSummaryCoreAsync(now, cancellationToken)
 1673            .ConfigureAwait(false);
 1674        _metrics.Update(summary);
 1675        return summary;
 1876    }
 77
 78    public async Task<ApplicationAuditReconciliationSummary> GetSummaryAsync(
 79        CancellationToken cancellationToken = default)
 80    {
 081        cancellationToken.ThrowIfCancellationRequested();
 082        if (!_options.Enabled)
 83        {
 084            return ApplicationAuditReconciliationMetrics.DisabledSummary;
 85        }
 86
 087        ApplicationAuditReconciliationSummary summary = await GetSummaryCoreAsync(
 088                _metrics.LastRunUtc,
 089                cancellationToken)
 090            .ConfigureAwait(false);
 091        _metrics.Update(summary);
 092        return summary;
 093    }
 94
 95    public async Task<IReadOnlyList<ApplicationAuditReconciliationFindingItem>> QueryFindingsAsync(
 96        ApplicationAuditReconciliationQuery request,
 97        CancellationToken cancellationToken = default)
 98    {
 099        ArgumentNullException.ThrowIfNull(request);
 0100        cancellationToken.ThrowIfCancellationRequested();
 101
 0102        IQueryable<ApplicationAuditReconciliationFinding> query = _dbContext
 0103            .ApplicationAuditReconciliationFindings
 0104            .AsNoTracking();
 105
 0106        if (!string.IsNullOrWhiteSpace(request.ReasonCode))
 107        {
 0108            string reasonCode = request.ReasonCode.Trim();
 0109            query = query.Where(finding => finding.ReasonCode == reasonCode);
 110        }
 111
 0112        if (!string.IsNullOrWhiteSpace(request.Severity))
 113        {
 0114            string severity = request.Severity.Trim();
 0115            query = query.Where(finding => finding.Severity == severity);
 116        }
 117
 0118        if (!string.IsNullOrWhiteSpace(request.MutationBatchId))
 119        {
 0120            string batchId = request.MutationBatchId.Trim();
 0121            query = query.Where(finding => finding.MutationBatchId == batchId);
 122        }
 123
 0124        if (!string.IsNullOrWhiteSpace(request.RemediationStatus))
 125        {
 0126            string status = request.RemediationStatus.Trim();
 0127            query = query.Where(finding => finding.RemediationStatus == status);
 128        }
 129
 0130        int maximumResults = Math.Clamp(request.MaximumResults, 1, 500);
 0131        return await query
 0132            .OrderByDescending(finding => finding.LastObservedUtc)
 0133            .Take(maximumResults)
 0134            .Select(finding => new ApplicationAuditReconciliationFindingItem(
 0135                finding.Id,
 0136                finding.SchemaVersion,
 0137                finding.FindingKey,
 0138                finding.ReasonCode,
 0139                finding.Severity,
 0140                finding.MutationBatchId,
 0141                finding.Destination,
 0142                finding.Guidance,
 0143                finding.RemediationStatus,
 0144                finding.FirstObservedUtc,
 0145                finding.LastObservedUtc,
 0146                finding.ResolvedUtc))
 0147            .ToListAsync(cancellationToken)
 0148            .ConfigureAwait(false);
 0149    }
 150
 151    public async Task<ApplicationAuditReconciliationRemediationItem> RecordRemediationAsync(
 152        Guid findingId,
 153        ApplicationAuditReconciliationRemediationRequest request,
 154        CancellationToken cancellationToken = default)
 155    {
 2156        ArgumentNullException.ThrowIfNull(request);
 2157        cancellationToken.ThrowIfCancellationRequested();
 158
 2159        ApplicationAuditReconciliationFinding finding = await _dbContext
 2160            .ApplicationAuditReconciliationFindings
 2161            .AsNoTracking()
 2162            .SingleOrDefaultAsync(item => item.Id == findingId, cancellationToken)
 2163            .ConfigureAwait(false)
 2164            ?? throw new KeyNotFoundException($"Audit reconciliation finding '{findingId}' was not found.");
 165
 2166        string actionCode = NormalizeRequired(request.ActionCode, 64, nameof(request.ActionCode));
 2167        string actorId = NormalizeRequired(request.ActorId, 256, nameof(request.ActorId));
 2168        string? evidenceReference = NormalizeOptional(request.EvidenceReference, 256);
 2169        DateTime now = UtcNow();
 2170        var remediationId = Guid.NewGuid();
 2171        string concurrencyStamp = Guid.NewGuid().ToString("N");
 172
 2173        await _dbContext.Database.ExecuteSqlInterpolatedAsync($$"""
 2174            INSERT INTO [ApplicationAuditReconciliationRemediations]
 2175                ([Id], [FindingId], [MutationBatchId], [ActionCode], [ActorId], [EvidenceReference], [RecordedUtc], [Con
 2176            VALUES
 2177                ({{remediationId}}, {{findingId}}, {{finding.MutationBatchId}}, {{actionCode}}, {{actorId}}, {{evidenceR
 2178            """, cancellationToken).ConfigureAwait(false);
 179
 2180        string remediationStatus = request.ResolveFinding
 2181            ? ApplicationAuditReconciliationRemediationStatuses.Resolved
 2182            : ApplicationAuditReconciliationRemediationStatuses.Acknowledged;
 2183        DateTime? resolvedUtc = request.ResolveFinding ? now : null;
 184
 2185        await _dbContext.Database.ExecuteSqlInterpolatedAsync($$"""
 2186            UPDATE [ApplicationAuditReconciliationFindings]
 2187            SET [RemediationStatus] = {{remediationStatus}},
 2188                [ResolvedUtc] = {{resolvedUtc}},
 2189                [ConcurrencyStamp] = {{Guid.NewGuid().ToString("N")}}
 2190            WHERE [Id] = {{findingId}}
 2191            """, cancellationToken).ConfigureAwait(false);
 192
 2193        return new(
 2194            remediationId,
 2195            findingId,
 2196            finding.MutationBatchId,
 2197            actionCode,
 2198            actorId,
 2199            evidenceReference,
 2200            now);
 2201    }
 202
 203    private List<ApplicationAuditReconciliationCandidate> BuildCandidates(
 204        IReadOnlyCollection<AuditRecord> auditRecords,
 205        IReadOnlyCollection<ApplicationAuditCompletionOutboxEntry> completionEntries,
 206        DateTime now)
 207    {
 16208        var candidates = new Dictionary<string, ApplicationAuditReconciliationCandidate>(StringComparer.Ordinal);
 16209        ILookup<string, AuditRecord> auditBatches = auditRecords
 16210            .Where(record => !string.IsNullOrWhiteSpace(record.MutationBatchId))
 32211            .ToLookup(record => record.MutationBatchId, StringComparer.Ordinal);
 16212        ILookup<string, ApplicationAuditCompletionOutboxEntry> completionBatches = completionEntries
 12213            .Where(entry => !string.IsNullOrWhiteSpace(entry.MutationBatchId))
 28214            .ToLookup(entry => entry.MutationBatchId, StringComparer.Ordinal);
 215
 48216        foreach (AuditRecord malformed in auditRecords.Where(record => string.IsNullOrWhiteSpace(record.MutationBatchId)
 217        {
 0218            Add(candidates, Candidate(
 0219                ApplicationAuditReconciliationReasonCodes.MalformedCorrelation,
 0220                ApplicationAuditReconciliationSeverities.Error,
 0221                $"missing-{malformed.Id:N}",
 0222                null,
 0223                "Retain the row, investigate the originating save path, and append remediation evidence."));
 224        }
 225
 64226        foreach (IGrouping<string, AuditRecord> batch in auditBatches)
 227        {
 16228            List<AuditRecord> records = [.. batch];
 16229            List<ApplicationAuditCompletionOutboxEntry> completions = [.. completionBatches[batch.Key]];
 32230            DateTime newestRecordUtc = records.Max(record => record.ModifiedOnUtc);
 231
 16232            if (completions.Count == 0 && newestRecordUtc <= now - _options.CompletionGracePeriod)
 233            {
 6234                Add(candidates, Candidate(
 6235                    ApplicationAuditReconciliationReasonCodes.MissingCompletion,
 6236                    ApplicationAuditReconciliationSeverities.Critical,
 6237                    batch.Key,
 6238                    null,
 6239                    "Preserve the audit batch, investigate transaction completion, and append an operator remediation re
 240            }
 241
 56242            foreach (ApplicationAuditCompletionOutboxEntry completion in completions)
 243            {
 12244                if (completion.AuditRecordCount != records.Count)
 245                {
 2246                    Add(candidates, Candidate(
 2247                        ApplicationAuditReconciliationReasonCodes.AuditRecordCountMismatch,
 2248                        ApplicationAuditReconciliationSeverities.Critical,
 2249                        batch.Key,
 2250                        completion.Destination,
 2251                        "Do not rewrite audit rows; compare retained evidence with the originating transaction and docum
 252                }
 253
 12254                ApplicationMutationAuditReceipt receipt = ToReceipt(completion);
 12255                if (!_manifestVerifier.Verify(receipt, records))
 256                {
 4257                    Add(candidates, Candidate(
 4258                        ApplicationAuditReconciliationReasonCodes.ManifestVerificationFailed,
 4259                        ApplicationAuditReconciliationSeverities.Critical,
 4260                        batch.Key,
 4261                        completion.Destination,
 4262                        "Quarantine downstream use of the batch, preserve all records, and investigate unauthorized or i
 263                }
 264            }
 265
 32266            if (records.Any(record => record.State == "Added" && string.IsNullOrWhiteSpace(record.KeyValues)))
 267            {
 0268                Add(candidates, Candidate(
 0269                    ApplicationAuditReconciliationReasonCodes.IncompleteGeneratedValues,
 0270                    ApplicationAuditReconciliationSeverities.Error,
 0271                    batch.Key,
 0272                    null,
 0273                    "Verify generated keys in the business database and append remediation evidence without modifying th
 274            }
 275
 16276            if (HasMalformedCorrelation(records))
 277            {
 0278                Add(candidates, Candidate(
 0279                    ApplicationAuditReconciliationReasonCodes.MalformedCorrelation,
 0280                    ApplicationAuditReconciliationSeverities.Warning,
 0281                    batch.Key,
 0282                    null,
 0283                    "Investigate inconsistent correlation metadata and preserve the original records as evidence."));
 284            }
 285        }
 286
 52287        foreach (IGrouping<string, ApplicationAuditCompletionOutboxEntry> batch in completionBatches)
 288        {
 10289            if (!auditBatches.Contains(batch.Key))
 290            {
 0291                foreach (ApplicationAuditCompletionOutboxEntry completion in batch)
 292                {
 0293                    Add(candidates, Candidate(
 0294                        ApplicationAuditReconciliationReasonCodes.MissingAuditBatch,
 0295                        ApplicationAuditReconciliationSeverities.Critical,
 0296                        batch.Key,
 0297                        completion.Destination,
 0298                        "Preserve the completion record and investigate missing or externally stored audit evidence."));
 299                }
 300            }
 301        }
 302
 36303        foreach (IGrouping<(string MutationBatchId, string Destination), ApplicationAuditCompletionOutboxEntry> duplicat
 28304            completionEntries.GroupBy(entry => (entry.MutationBatchId, entry.Destination)))
 305        {
 10306            if (duplicate.Count() > 1)
 307            {
 2308                Add(candidates, Candidate(
 2309                    ApplicationAuditReconciliationReasonCodes.DuplicateCompletion,
 2310                    ApplicationAuditReconciliationSeverities.Critical,
 2311                    duplicate.Key.MutationBatchId,
 2312                    duplicate.Key.Destination,
 2313                    "Preserve all records, stop dispatch for the destination, and investigate uniqueness or migration dr
 314            }
 315        }
 316
 56317        foreach (ApplicationAuditCompletionOutboxEntry entry in completionEntries)
 318        {
 12319            AddDeliveryCandidate(candidates, entry, now);
 320        }
 321
 16322        return [.. candidates.Values];
 323    }
 324
 325    private void AddDeliveryCandidate(
 326        IDictionary<string, ApplicationAuditReconciliationCandidate> candidates,
 327        ApplicationAuditCompletionOutboxEntry entry,
 328        DateTime now)
 329    {
 12330        if ((entry.Status == ApplicationAuditCompletionOutboxStatuses.Pending ||
 12331             entry.Status == ApplicationAuditCompletionOutboxStatuses.Deferred) &&
 12332            entry.CreatedUtc <= now - _options.StalePendingThreshold)
 333        {
 2334            Add(candidates, Candidate(
 2335                ApplicationAuditReconciliationReasonCodes.StalePending,
 2336                ApplicationAuditReconciliationSeverities.Warning,
 2337                entry.MutationBatchId,
 2338                entry.Destination,
 2339                "Verify dispatcher availability and destination registration before retrying delivery."));
 340        }
 10341        else if (entry.Status == ApplicationAuditCompletionOutboxStatuses.RetryableFailure &&
 10342                 entry.NextAttemptUtc <= now - _options.StaleRetryReadyThreshold)
 343        {
 0344            Add(candidates, Candidate(
 0345                ApplicationAuditReconciliationReasonCodes.StaleRetryReady,
 0346                ApplicationAuditReconciliationSeverities.Error,
 0347                entry.MutationBatchId,
 0348                entry.Destination,
 0349                "Inspect destination availability and retry policy; preserve prior attempt diagnostics."));
 350        }
 10351        else if (entry.Status == ApplicationAuditCompletionOutboxStatuses.Failed)
 352        {
 0353            Add(candidates, Candidate(
 0354                ApplicationAuditReconciliationReasonCodes.DeliveryFailed,
 0355                ApplicationAuditReconciliationSeverities.Error,
 0356                entry.MutationBatchId,
 0357                entry.Destination,
 0358                "Investigate the terminal delivery failure and append operator remediation evidence."));
 359        }
 10360        else if (entry.Status == ApplicationAuditCompletionOutboxStatuses.DeadLettered)
 361        {
 0362            Add(candidates, Candidate(
 0363                ApplicationAuditReconciliationReasonCodes.DeadLettered,
 0364                ApplicationAuditReconciliationSeverities.Critical,
 0365                entry.MutationBatchId,
 0366                entry.Destination,
 0367                "Review the dead letter, preserve diagnostics, correct the destination, and explicitly requeue only unde
 368        }
 10369    }
 370
 371    private async Task PersistCandidatesAsync(
 372        IReadOnlyCollection<ApplicationAuditReconciliationCandidate> candidates,
 373        IReadOnlyCollection<string> auditBatchIds,
 374        IReadOnlyCollection<ApplicationAuditCompletionOutboxEntry> completionEntries,
 375        DateTime now,
 376        CancellationToken cancellationToken)
 377    {
 378        string[] keys = [.. candidates.Select(candidate => candidate.FindingKey)];
 16379        List<ApplicationAuditReconciliationFinding> existing = await _dbContext
 16380            .ApplicationAuditReconciliationFindings
 16381            .AsNoTracking()
 16382            .Where(finding => keys.Contains(finding.FindingKey))
 16383            .ToListAsync(cancellationToken)
 16384            .ConfigureAwait(false);
 16385        var existingByKey = existing
 386            .ToDictionary(finding => finding.FindingKey, StringComparer.Ordinal);
 387
 64388        foreach (ApplicationAuditReconciliationCandidate candidate in candidates)
 389        {
 16390            if (existingByKey.TryGetValue(candidate.FindingKey, out ApplicationAuditReconciliationFinding? finding))
 391            {
 2392                await _dbContext.Database.ExecuteSqlInterpolatedAsync($$"""
 2393                    UPDATE [ApplicationAuditReconciliationFindings]
 2394                    SET [Severity] = {{candidate.Severity}},
 2395                        [Guidance] = {{candidate.Guidance}},
 2396                        [LastObservedUtc] = {{now}},
 2397                        [RemediationStatus] = {{ApplicationAuditReconciliationRemediationStatuses.Open}},
 2398                        [ResolvedUtc] = {{(DateTime?)null}},
 2399                        [ConcurrencyStamp] = {{Guid.NewGuid().ToString("N")}}
 2400                    WHERE [Id] = {{finding.Id}}
 2401                    """, cancellationToken).ConfigureAwait(false);
 402            }
 403            else
 404            {
 14405                var id = Guid.NewGuid();
 14406                await _dbContext.Database.ExecuteSqlInterpolatedAsync($$"""
 14407                    INSERT INTO [ApplicationAuditReconciliationFindings]
 14408                        ([Id], [SchemaVersion], [FindingKey], [ReasonCode], [Severity], [MutationBatchId], [Destination]
 14409                    VALUES
 14410                        ({{id}}, {{ApplicationAuditReconciliationFinding.CurrentSchemaVersion}}, {{candidate.FindingKey}
 14411                    """, cancellationToken).ConfigureAwait(false);
 412            }
 413        }
 414
 16415        string[] scopeBatchIds = [.. auditBatchIds
 416            .Concat(completionEntries.Select(entry => entry.MutationBatchId))
 417            .Where(batchId => !string.IsNullOrWhiteSpace(batchId))
 16418            .Distinct(StringComparer.Ordinal)];
 16419        string[] activeKeys = keys;
 420
 16421        List<ApplicationAuditReconciliationFinding> resolved = await _dbContext
 16422            .ApplicationAuditReconciliationFindings
 16423            .AsNoTracking()
 16424            .Where(finding => scopeBatchIds.Contains(finding.MutationBatchId) &&
 16425                finding.RemediationStatus != ApplicationAuditReconciliationRemediationStatuses.Resolved &&
 16426                !activeKeys.Contains(finding.FindingKey))
 16427            .ToListAsync(cancellationToken)
 16428            .ConfigureAwait(false);
 429
 32430        foreach (ApplicationAuditReconciliationFinding finding in resolved)
 431        {
 0432            await _dbContext.Database.ExecuteSqlInterpolatedAsync($$"""
 0433                UPDATE [ApplicationAuditReconciliationFindings]
 0434                SET [RemediationStatus] = {{ApplicationAuditReconciliationRemediationStatuses.Resolved}},
 0435                    [ResolvedUtc] = {{now}},
 0436                    [ConcurrencyStamp] = {{Guid.NewGuid().ToString("N")}}
 0437                WHERE [Id] = {{finding.Id}}
 0438                """, cancellationToken).ConfigureAwait(false);
 439        }
 16440    }
 441
 442    private async Task<ApplicationAuditReconciliationSummary> GetSummaryCoreAsync(
 443        DateTime? lastRunUtc,
 444        CancellationToken cancellationToken)
 445    {
 16446        IQueryable<ApplicationAuditReconciliationFinding> open = _dbContext
 16447            .ApplicationAuditReconciliationFindings
 16448            .AsNoTracking()
 16449            .Where(finding => finding.RemediationStatus != ApplicationAuditReconciliationRemediationStatuses.Resolved);
 450
 16451        long openCount = await open.LongCountAsync(cancellationToken).ConfigureAwait(false);
 16452        long errorCount = await open.LongCountAsync(
 16453            finding => finding.Severity == ApplicationAuditReconciliationSeverities.Error,
 16454            cancellationToken).ConfigureAwait(false);
 16455        long criticalCount = await open.LongCountAsync(
 16456            finding => finding.Severity == ApplicationAuditReconciliationSeverities.Critical,
 16457            cancellationToken).ConfigureAwait(false);
 16458        long manifestFailures = await open.LongCountAsync(
 16459            finding => finding.ReasonCode == ApplicationAuditReconciliationReasonCodes.ManifestVerificationFailed,
 16460            cancellationToken).ConfigureAwait(false);
 16461        long missingCompletion = await open.LongCountAsync(
 16462            finding => finding.ReasonCode == ApplicationAuditReconciliationReasonCodes.MissingCompletion,
 16463            cancellationToken).ConfigureAwait(false);
 16464        long staleDelivery = await open.LongCountAsync(
 16465            finding => finding.ReasonCode == ApplicationAuditReconciliationReasonCodes.StalePending ||
 16466                finding.ReasonCode == ApplicationAuditReconciliationReasonCodes.StaleRetryReady ||
 16467                finding.ReasonCode == ApplicationAuditReconciliationReasonCodes.DeliveryFailed,
 16468            cancellationToken).ConfigureAwait(false);
 16469        long deadLetters = await open.LongCountAsync(
 16470            finding => finding.ReasonCode == ApplicationAuditReconciliationReasonCodes.DeadLettered,
 16471            cancellationToken).ConfigureAwait(false);
 472
 16473        return new(
 16474            true,
 16475            lastRunUtc,
 16476            openCount,
 16477            errorCount,
 16478            criticalCount,
 16479            manifestFailures,
 16480            missingCompletion,
 16481            staleDelivery,
 16482            deadLetters);
 16483    }
 484
 485    private static bool HasMalformedCorrelation(IReadOnlyCollection<AuditRecord> records)
 486    {
 16487        return records.Any(record =>
 16488                IsWhitespaceOnly(record.OperationExecutionId) ||
 16489                IsWhitespaceOnly(record.ExecutionAttemptId) ||
 16490                IsWhitespaceOnly(record.DecisionAuditRecordId) ||
 16491                IsWhitespaceOnly(record.CorrelationId) ||
 16492                IsWhitespaceOnly(record.TraceId)) ||
 16493            HasMultipleValues(records.Select(record => record.OperationExecutionId)) ||
 16494            HasMultipleValues(records.Select(record => record.ExecutionAttemptId)) ||
 16495            HasMultipleValues(records.Select(record => record.DecisionAuditRecordId)) ||
 16496            HasMultipleValues(records.Select(record => record.CorrelationId)) ||
 32497            HasMultipleValues(records.Select(record => record.TraceId));
 498    }
 499
 500    private static bool HasMultipleValues(IEnumerable<string?> values)
 501    {
 80502        return values
 80503            .Where(value => !string.IsNullOrWhiteSpace(value))
 0504            .Select(value => value!.Trim())
 80505            .Distinct(StringComparer.Ordinal)
 80506            .Skip(1)
 80507            .Any();
 508    }
 509
 510    private static bool IsWhitespaceOnly(string? value)
 511    {
 80512        return value is not null && string.IsNullOrWhiteSpace(value);
 513    }
 514
 515    private static ApplicationMutationAuditReceipt ToReceipt(ApplicationAuditCompletionOutboxEntry entry)
 516    {
 12517        return new(
 12518            entry.MutationBatchId,
 12519            entry.AuditRecordCount,
 12520            entry.PersistenceOutcome,
 12521            new DateTimeOffset(DateTime.SpecifyKind(entry.ReceiptCompletedUtc, DateTimeKind.Utc)),
 12522            entry.MutationManifestHash,
 12523            entry.MutationManifestAlgorithm,
 12524            entry.MutationManifestSchemaVersion,
 12525            entry.OperationExecutionId,
 12526            entry.ExecutionAttemptId,
 12527            entry.DecisionAuditRecordId,
 12528            entry.CorrelationId,
 12529            entry.TraceId);
 530    }
 531
 532    private static ApplicationAuditReconciliationCandidate Candidate(
 533        string reasonCode,
 534        string severity,
 535        string batchId,
 536        string? destination,
 537        string guidance)
 538    {
 16539        string normalizedBatchId = NormalizeRequired(batchId, 64, nameof(batchId));
 16540        string? normalizedDestination = NormalizeOptional(destination, 128);
 16541        string identity = $"{reasonCode}\n{normalizedBatchId}\n{normalizedDestination}";
 16542        string key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
 16543        return new(key, reasonCode, severity, normalizedBatchId, normalizedDestination, guidance);
 544    }
 545
 546    private static void Add(
 547        IDictionary<string, ApplicationAuditReconciliationCandidate> candidates,
 548        ApplicationAuditReconciliationCandidate candidate)
 549    {
 16550        candidates[candidate.FindingKey] = candidate;
 16551    }
 552
 553    private static string NormalizeRequired(string value, int maximumLength, string parameterName)
 554    {
 20555        ArgumentException.ThrowIfNullOrWhiteSpace(value, parameterName);
 20556        string normalized = value.Trim();
 20557        return normalized.Length <= maximumLength
 20558            ? normalized
 20559            : throw new ArgumentOutOfRangeException(parameterName, $"Value cannot exceed {maximumLength} characters.");
 560    }
 561
 562    private static string? NormalizeOptional(string? value, int maximumLength)
 563    {
 18564        if (string.IsNullOrWhiteSpace(value))
 565        {
 6566            return null;
 567        }
 568
 12569        string normalized = value.Trim();
 12570        return normalized.Length <= maximumLength
 12571            ? normalized
 12572            : normalized[..maximumLength];
 573    }
 574
 575    private DateTime UtcNow()
 576    {
 18577        return _timeProvider.GetUtcNow().UtcDateTime;
 578    }
 579}

Methods/Properties

.ctor(ProjectTemplate.Infrastructure.Data.ApplicationDbContext,ProjectTemplate.Infrastructure.Data.Auditing.IApplicationMutationManifestVerifier,Microsoft.Extensions.Options.IOptions`1<ProjectTemplate.Infrastructure.Data.Auditing.ApplicationAuditReconciliationOptions>,ProjectTemplate.Infrastructure.Data.Auditing.ApplicationAuditReconciliationMetrics,System.TimeProvider)
ReconcileAsync()
GetSummaryAsync()
QueryFindingsAsync()
RecordRemediationAsync()
BuildCandidates(System.Collections.Generic.IReadOnlyCollection`1<ProjectTemplate.Infrastructure.Data.Entities.AuditRecord>,System.Collections.Generic.IReadOnlyCollection`1<ProjectTemplate.Infrastructure.Data.Entities.ApplicationAuditCompletionOutboxEntry>,System.DateTime)
AddDeliveryCandidate(System.Collections.Generic.IDictionary`2<System.String,ProjectTemplate.Infrastructure.Data.Auditing.ApplicationAuditReconciliationCandidate>,ProjectTemplate.Infrastructure.Data.Entities.ApplicationAuditCompletionOutboxEntry,System.DateTime)
PersistCandidatesAsync()
GetSummaryCoreAsync()
HasMalformedCorrelation(System.Collections.Generic.IReadOnlyCollection`1<ProjectTemplate.Infrastructure.Data.Entities.AuditRecord>)
HasMultipleValues(System.Collections.Generic.IEnumerable`1<System.String>)
IsWhitespaceOnly(System.String)
ToReceipt(ProjectTemplate.Infrastructure.Data.Entities.ApplicationAuditCompletionOutboxEntry)
Candidate(System.String,System.String,System.String,System.String,System.String)
Add(System.Collections.Generic.IDictionary`2<System.String,ProjectTemplate.Infrastructure.Data.Auditing.ApplicationAuditReconciliationCandidate>,ProjectTemplate.Infrastructure.Data.Auditing.ApplicationAuditReconciliationCandidate)
NormalizeRequired(System.String,System.Int32,System.String)
NormalizeOptional(System.String,System.Int32)
UtcNow()