feat(audit)!: ScadaBridge C3 — swap to canonical ZB.MOM.WW.Audit.AuditEvent across seams/emitters/DTO/redactor wiring; transitional 24-col storage shim (Task 2.5)
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||||
@@ -13,7 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
|||||||
/// Central-side singleton (per Bundle E wiring) that ingests batches of
|
/// Central-side singleton (per Bundle E wiring) that ingests batches of
|
||||||
/// <see cref="AuditEvent"/> rows pushed from sites via the
|
/// <see cref="AuditEvent"/> rows pushed from sites via the
|
||||||
/// <c>IngestAuditEvents</c> gRPC RPC. Each row is stamped with the central-side
|
/// <c>IngestAuditEvents</c> gRPC RPC. Each row is stamped with the central-side
|
||||||
/// <see cref="AuditEvent.IngestedAtUtc"/> and inserted idempotently via
|
/// the central-side IngestedAtUtc (in DetailsJson) and inserted idempotently via
|
||||||
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> — duplicates are
|
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> — duplicates are
|
||||||
/// silently swallowed (first-write-wins per Bundle A's hardening).
|
/// silently swallowed (first-write-wins per Bundle A's hardening).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -127,19 +128,19 @@ public class AuditLogIngestActor : ReceiveActor
|
|||||||
// without blocking on sync Dispose() of pending connection cleanup.
|
// without blocking on sync Dispose() of pending connection cleanup.
|
||||||
if (_injectedRepository is not null)
|
if (_injectedRepository is not null)
|
||||||
{
|
{
|
||||||
await IngestWithRepositoryAsync(_injectedRepository, filter: null, failureCounter: null, cmd, nowUtc, accepted)
|
await IngestWithRepositoryAsync(_injectedRepository, redactor: null, failureCounter: null, cmd, nowUtc, accepted)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await using var scope = _serviceProvider!.CreateAsyncScope();
|
await using var scope = _serviceProvider!.CreateAsyncScope();
|
||||||
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||||
var filter = scope.ServiceProvider.GetService<IAuditPayloadFilter>();
|
var redactor = scope.ServiceProvider.GetService<IAuditRedactor>();
|
||||||
// M6 Bundle E (T8): central health counter is best-effort —
|
// M6 Bundle E (T8): central health counter is best-effort —
|
||||||
// unregistered (test composition roots) means the per-row catch
|
// unregistered (test composition roots) means the per-row catch
|
||||||
// simply logs without surfacing on the health dashboard.
|
// simply logs without surfacing on the health dashboard.
|
||||||
var failureCounter = scope.ServiceProvider.GetService<ICentralAuditWriteFailureCounter>();
|
var failureCounter = scope.ServiceProvider.GetService<ICentralAuditWriteFailureCounter>();
|
||||||
await IngestWithRepositoryAsync(repository, filter, failureCounter, cmd, nowUtc, accepted)
|
await IngestWithRepositoryAsync(repository, redactor, failureCounter, cmd, nowUtc, accepted)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +149,7 @@ public class AuditLogIngestActor : ReceiveActor
|
|||||||
|
|
||||||
private async Task IngestWithRepositoryAsync(
|
private async Task IngestWithRepositoryAsync(
|
||||||
IAuditLogRepository repository,
|
IAuditLogRepository repository,
|
||||||
IAuditPayloadFilter? filter,
|
IAuditRedactor? redactor,
|
||||||
ICentralAuditWriteFailureCounter? failureCounter,
|
ICentralAuditWriteFailureCounter? failureCounter,
|
||||||
IngestAuditEventsCommand cmd,
|
IngestAuditEventsCommand cmd,
|
||||||
DateTime nowUtc,
|
DateTime nowUtc,
|
||||||
@@ -162,15 +163,17 @@ public class AuditLogIngestActor : ReceiveActor
|
|||||||
// repository hardening already swallows duplicate-key races,
|
// repository hardening already swallows duplicate-key races,
|
||||||
// so the same id arriving twice (site retry, reconciliation)
|
// so the same id arriving twice (site retry, reconciliation)
|
||||||
// is a silent no-op.
|
// is a silent no-op.
|
||||||
// Filter BEFORE the IngestedAtUtc stamp so the redacted
|
// Redact BEFORE the IngestedAtUtc stamp so the redacted
|
||||||
// copy carries the central-side ingest timestamp. Filter
|
// copy carries the central-side ingest timestamp. The redactor
|
||||||
// is contract-bound to never throw. AuditLog-008: a null
|
// is contract-bound to never throw. AuditLog-008: a null
|
||||||
// filter (test composition root, no IAuditPayloadFilter
|
// redactor (test composition root, no IAuditRedactor
|
||||||
// registered) now falls back to the SafeDefault rather than
|
// registered) now falls back to the SafeDefault rather than
|
||||||
// pass-through, so HTTP header redaction always runs.
|
// pass-through, so HTTP header redaction always runs.
|
||||||
var safeFilter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
|
// C3 transitional shim: IngestedAtUtc is a DetailsJson field on
|
||||||
var filtered = safeFilter.Apply(evt);
|
// the canonical record, so stamp it via the projection helper.
|
||||||
var ingested = filtered with { IngestedAtUtc = nowUtc };
|
var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
|
||||||
|
var filtered = safeRedactor.Apply(evt);
|
||||||
|
var ingested = AuditRowProjection.WithIngestedAtUtc(filtered, nowUtc);
|
||||||
await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
|
await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
|
||||||
accepted.Add(evt.EventId);
|
accepted.Add(evt.EventId);
|
||||||
}
|
}
|
||||||
@@ -216,12 +219,12 @@ public class AuditLogIngestActor : ReceiveActor
|
|||||||
var auditRepo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
var auditRepo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||||
var siteCallRepo = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
|
var siteCallRepo = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
// Bundle C (M5-T6): resolve the filter for the whole batch from
|
// Bundle C (M5-T6): resolve the redactor for the whole batch from
|
||||||
// the scope; null = pass-through for test composition roots that
|
// the scope; null = SafeDefault for test composition roots that
|
||||||
// skip the filter registration. The filter is contract-bound to
|
// skip the redactor registration. The redactor is contract-bound to
|
||||||
// never throw, so we can apply it inside the per-entry try
|
// never throw, so we can apply it inside the per-entry try
|
||||||
// without risking an unbounded blast radius.
|
// without risking an unbounded blast radius.
|
||||||
var filter = scope.ServiceProvider.GetService<IAuditPayloadFilter>();
|
var redactor = scope.ServiceProvider.GetService<IAuditRedactor>();
|
||||||
// M6 Bundle E (T8): same best-effort central health counter as
|
// M6 Bundle E (T8): same best-effort central health counter as
|
||||||
// the OnIngestAsync path — null on test composition roots that
|
// the OnIngestAsync path — null on test composition roots that
|
||||||
// skip the registration.
|
// skip the registration.
|
||||||
@@ -240,14 +243,16 @@ public class AuditLogIngestActor : ReceiveActor
|
|||||||
// matching timestamps (debugging convenience, not a
|
// matching timestamps (debugging convenience, not a
|
||||||
// correctness invariant).
|
// correctness invariant).
|
||||||
var ingestedAt = DateTime.UtcNow;
|
var ingestedAt = DateTime.UtcNow;
|
||||||
// Filter the audit half BEFORE the dual-write — only the
|
// Redact the audit half BEFORE the dual-write — only the
|
||||||
// AuditLog row's payload columns are filterable; SiteCalls
|
// AuditLog row's payload columns are redactable; SiteCalls
|
||||||
// carries operational state only (status, retry count) and
|
// carries operational state only (status, retry count) and
|
||||||
// is left untouched. AuditLog-008: null filter falls back
|
// is left untouched. AuditLog-008: null redactor falls back
|
||||||
// to SafeDefault so header redaction always runs.
|
// to SafeDefault so header redaction always runs.
|
||||||
var safeFilter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
|
// C3 transitional shim: IngestedAtUtc is a DetailsJson field
|
||||||
var filteredAudit = safeFilter.Apply(entry.Audit);
|
// on the canonical record, so stamp it via the projection helper.
|
||||||
var auditStamped = filteredAudit with { IngestedAtUtc = ingestedAt };
|
var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
|
||||||
|
var filteredAudit = safeRedactor.Apply(entry.Audit);
|
||||||
|
var auditStamped = AuditRowProjection.WithIngestedAtUtc(filteredAudit, ingestedAt);
|
||||||
var siteCallStamped = entry.SiteCall with { IngestedAtUtc = ingestedAt };
|
var siteCallStamped = entry.SiteCall with { IngestedAtUtc = ingestedAt };
|
||||||
|
|
||||||
await auditRepo.InsertIfNotExistsAsync(auditStamped)
|
await auditRepo.InsertIfNotExistsAsync(auditStamped)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
|||||||
{
|
{
|
||||||
private readonly IServiceProvider _services;
|
private readonly IServiceProvider _services;
|
||||||
private readonly ILogger<CentralAuditWriter> _logger;
|
private readonly ILogger<CentralAuditWriter> _logger;
|
||||||
private readonly IAuditPayloadFilter _filter;
|
private readonly IAuditRedactor _redactor;
|
||||||
private readonly ICentralAuditWriteFailureCounter _failureCounter;
|
private readonly ICentralAuditWriteFailureCounter _failureCounter;
|
||||||
private readonly INodeIdentityProvider? _nodeIdentity;
|
private readonly INodeIdentityProvider? _nodeIdentity;
|
||||||
|
|
||||||
@@ -68,24 +69,25 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="services">Service provider used to open a per-call scope for the scoped repository.</param>
|
/// <param name="services">Service provider used to open a per-call scope for the scoped repository.</param>
|
||||||
/// <param name="logger">Logger for swallowed write-failure diagnostics.</param>
|
/// <param name="logger">Logger for swallowed write-failure diagnostics.</param>
|
||||||
/// <param name="filter">Optional payload filter for truncation and redaction; defaults to a pass-through.</param>
|
/// <param name="redactor">Optional canonical redactor for truncation and redaction; defaults to the always-safe default.</param>
|
||||||
/// <param name="failureCounter">Optional counter incremented on swallowed repository failures; defaults to a no-op.</param>
|
/// <param name="failureCounter">Optional counter incremented on swallowed repository failures; defaults to a no-op.</param>
|
||||||
/// <param name="nodeIdentity">Optional node identity provider for stamping <c>SourceNode</c> on central-origin rows.</param>
|
/// <param name="nodeIdentity">Optional node identity provider for stamping <c>SourceNode</c> on central-origin rows.</param>
|
||||||
public CentralAuditWriter(
|
public CentralAuditWriter(
|
||||||
IServiceProvider services,
|
IServiceProvider services,
|
||||||
ILogger<CentralAuditWriter> logger,
|
ILogger<CentralAuditWriter> logger,
|
||||||
IAuditPayloadFilter? filter = null,
|
IAuditRedactor? redactor = null,
|
||||||
ICentralAuditWriteFailureCounter? failureCounter = null,
|
ICentralAuditWriteFailureCounter? failureCounter = null,
|
||||||
INodeIdentityProvider? nodeIdentity = null)
|
INodeIdentityProvider? nodeIdentity = null)
|
||||||
{
|
{
|
||||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
// AuditLog-008: never default to null — over-redact instead.
|
// AuditLog-008: never default to null — over-redact instead.
|
||||||
// SafeDefaultAuditPayloadFilter applies HTTP header redaction with
|
// C3 (Task 2.5): the canonical IAuditRedactor replaces the legacy
|
||||||
// hard-coded sensitive defaults so a composition root that omits the
|
// IAuditPayloadFilter. SafeDefaultAuditRedactor applies HTTP header
|
||||||
// real filter still scrubs Authorization / X-Api-Key / Cookie /
|
// redaction with hard-coded sensitive defaults so a composition root
|
||||||
// Set-Cookie before persistence.
|
// that omits the real redactor still scrubs Authorization / X-Api-Key /
|
||||||
_filter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
|
// Cookie / Set-Cookie before persistence.
|
||||||
|
_redactor = redactor ?? SafeDefaultAuditRedactor.Instance;
|
||||||
_failureCounter = failureCounter ?? new NoOpCentralAuditWriteFailureCounter();
|
_failureCounter = failureCounter ?? new NoOpCentralAuditWriteFailureCounter();
|
||||||
_nodeIdentity = nodeIdentity;
|
_nodeIdentity = nodeIdentity;
|
||||||
}
|
}
|
||||||
@@ -103,12 +105,12 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Filter BEFORE stamping IngestedAtUtc + handing to the repo. The
|
// Redact BEFORE stamping IngestedAtUtc + handing to the repo. The
|
||||||
// filter contract is "never throws". AuditLog-008: _filter is now
|
// redactor contract is "never throws". AuditLog-008: _redactor is
|
||||||
// non-null (SafeDefaultAuditPayloadFilter fallback) so header
|
// now non-null (SafeDefaultAuditRedactor fallback) so header
|
||||||
// redaction always runs even in composition roots that omit the
|
// redaction always runs even in composition roots that omit the
|
||||||
// real filter.
|
// real redactor.
|
||||||
var filtered = _filter.Apply(evt);
|
var filtered = _redactor.Apply(evt);
|
||||||
|
|
||||||
// SourceNode-stamping (Task 12): caller-provided value wins
|
// SourceNode-stamping (Task 12): caller-provided value wins
|
||||||
// (supports any future direct-write callsite that already has its
|
// (supports any future direct-write callsite that already has its
|
||||||
@@ -124,7 +126,9 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
|||||||
|
|
||||||
await using var scope = _services.CreateAsyncScope();
|
await using var scope = _services.CreateAsyncScope();
|
||||||
var repo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
var repo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||||
var stamped = filtered with { IngestedAtUtc = DateTime.UtcNow };
|
// C3 transitional shim: IngestedAtUtc is a DetailsJson field on the
|
||||||
|
// canonical record, so stamp it via the projection helper.
|
||||||
|
var stamped = AuditRowProjection.WithIngestedAtUtc(filtered, DateTime.UtcNow);
|
||||||
await repo.InsertIfNotExistsAsync(stamped, ct).ConfigureAwait(false);
|
await repo.InsertIfNotExistsAsync(stamped, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -143,17 +147,17 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
|||||||
// misbehaving custom counter does, swallowing here keeps the
|
// misbehaving custom counter does, swallowing here keeps the
|
||||||
// best-effort contract intact.
|
// best-effort contract intact.
|
||||||
}
|
}
|
||||||
// Log the input event's identifying fields. These three (EventId,
|
// Log the input event's identifying fields. EventId + Action are
|
||||||
// Kind, Status) are immutable across the filter+stamp chain — the
|
// immutable across the redact+stamp chain — the `with` clones above
|
||||||
// `with` clones above touch only SourceNode and IngestedAtUtc — so
|
// touch only SourceNode and DetailsJson — so referencing `evt` here
|
||||||
// referencing `evt` here is intentional and equivalent to the
|
// is intentional and equivalent to the stamped record for
|
||||||
// stamped record for diagnostics. If you add a field here that the
|
// diagnostics. Action = "{Channel}.{Kind}" carries the kind; the
|
||||||
// stamp chain DOES mutate (e.g., SourceNode), reference the latest
|
// canonical Outcome carries the coarse status (fine-grained Status
|
||||||
// post-stamp record name instead, not `evt`.
|
// lives in DetailsJson).
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
ex,
|
ex,
|
||||||
"CentralAuditWriter failed for EventId {EventId} (Kind={Kind}, Status={Status})",
|
"CentralAuditWriter failed for EventId {EventId} (Action={Action}, Outcome={Outcome})",
|
||||||
evt.EventId, evt.Kind, evt.Status);
|
evt.EventId, evt.Action, evt.Outcome);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ using Akka.Actor;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||||
|
|
||||||
@@ -258,7 +258,9 @@ public class SiteAuditReconciliationActor : ReceiveActor
|
|||||||
// concurrent push, or a retry of this very pull) collapse to
|
// concurrent push, or a retry of this very pull) collapse to
|
||||||
// a no-op courtesy of M2 Bundle A's race-fix on
|
// a no-op courtesy of M2 Bundle A's race-fix on
|
||||||
// InsertIfNotExistsAsync.
|
// InsertIfNotExistsAsync.
|
||||||
var ingested = evt with { IngestedAtUtc = nowUtc };
|
// C3: IngestedAtUtc is a DetailsJson field on the canonical record —
|
||||||
|
// stamp it via the projection helper.
|
||||||
|
var ingested = AuditRowProjection.WithIngestedAtUtc(evt, nowUtc);
|
||||||
await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
|
await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
|
||||||
_failedInsertAttempts.Remove(evt.EventId);
|
_failedInsertAttempts.Remove(evt.EventId);
|
||||||
advanceForThisRow = true;
|
advanceForThisRow = true;
|
||||||
@@ -299,9 +301,11 @@ public class SiteAuditReconciliationActor : ReceiveActor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (advanceForThisRow && evt.OccurredAtUtc > maxOccurred)
|
// C3: canonical OccurredAtUtc is a DateTimeOffset; the cursor is a UTC DateTime.
|
||||||
|
var occurredUtc = evt.OccurredAtUtc.UtcDateTime;
|
||||||
|
if (advanceForThisRow && occurredUtc > maxOccurred)
|
||||||
{
|
{
|
||||||
maxOccurred = evt.OccurredAtUtc;
|
maxOccurred = occurredUtc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,304 +0,0 @@
|
|||||||
using System.Text.RegularExpressions;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Default <see cref="IAuditPayloadFilter"/>. Bundle A established the
|
|
||||||
/// truncation backbone; Bundle B chains HTTP header redaction (M5-T3) BEFORE
|
|
||||||
/// truncation so redactors operate on the full payload and the cap then trims
|
|
||||||
/// the redacted result.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// Uses <see cref="IOptionsMonitor{TOptions}"/> (not <see cref="IOptions{TOptions}"/>)
|
|
||||||
/// so the M5-T8 hot-reload path sees fresh values without re-resolving the
|
|
||||||
/// singleton. <see cref="Apply"/> reads <see cref="IOptionsMonitor{T}.CurrentValue"/>
|
|
||||||
/// on every call, and the regex cache is keyed by pattern string — patterns
|
|
||||||
/// added via a live config change compile on first use of the next event;
|
|
||||||
/// patterns removed simply stop being looked up. No <c>OnChange</c> subscription
|
|
||||||
/// or explicit cache invalidation is required (the
|
|
||||||
/// <c>AuditLogOptionsBindingTests</c> fixture in <c>ZB.MOM.WW.ScadaBridge.AuditLog.Tests</c>
|
|
||||||
/// pins this behaviour).
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// "Error row" = <see cref="AuditEvent.Status"/> NOT IN (<c>Delivered</c>,
|
|
||||||
/// <c>Submitted</c>, <c>Forwarded</c>) — every other status, including the
|
|
||||||
/// non-terminal <c>Attempted</c>, the parked/discarded terminals, and the
|
|
||||||
/// short-circuit <c>Skipped</c>, receives the larger error cap so a verbose
|
|
||||||
/// error body survives.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Apply MUST NOT throw — on internal failure the filter over-redacts by
|
|
||||||
/// returning the input with <see cref="AuditEvent.PayloadTruncated"/> set and
|
|
||||||
/// increments the <c>AuditRedactionFailure</c> health metric via the injected
|
|
||||||
/// <see cref="IAuditRedactionFailureCounter"/>. Each redactor stage runs in
|
|
||||||
/// its own try/catch — a failure in (say) the header redactor still lets the
|
|
||||||
/// SQL parameter redactor and the truncator run on the remaining fields.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Stage order (each runs on every applicable field):
|
|
||||||
/// header redaction → body regex redaction → truncation. The SQL-parameter
|
|
||||||
/// stage piggybacks on the body-redactor path; both run BEFORE truncation so
|
|
||||||
/// the cap trims the redacted result, never bytes the redactor intended to
|
|
||||||
/// hide.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
|
|
||||||
{
|
|
||||||
// Redaction markers + the relaxed-escaping JSON options live in
|
|
||||||
// AuditRedactionPrimitives, and the compiled-regex cache (50 ms match
|
|
||||||
// timeout, 100 ms compile budget, invalid-pattern sentinel) lives in
|
|
||||||
// AuditRegexCache — both shared C2 helpers so the legacy filter and the
|
|
||||||
// canonical ScadaBridgeAuditRedactor emit byte-identical output.
|
|
||||||
private readonly IOptionsMonitor<AuditLogOptions> _options;
|
|
||||||
private readonly ILogger<DefaultAuditPayloadFilter> _logger;
|
|
||||||
private readonly IAuditRedactionFailureCounter _failureCounter;
|
|
||||||
private readonly AuditRegexCache _regexCache;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Primary constructor used by DI — pulls the optional redaction-failure
|
|
||||||
/// counter from the container; a NoOp default is registered in
|
|
||||||
/// <see cref="ServiceCollectionExtensions.AddAuditLog"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="options">Live-reloadable audit log options.</param>
|
|
||||||
/// <param name="logger">Logger for redaction diagnostics.</param>
|
|
||||||
/// <param name="failureCounter">Optional counter incremented when a redaction operation fails; defaults to a no-op.</param>
|
|
||||||
public DefaultAuditPayloadFilter(
|
|
||||||
IOptionsMonitor<AuditLogOptions> options,
|
|
||||||
ILogger<DefaultAuditPayloadFilter> logger,
|
|
||||||
IAuditRedactionFailureCounter? failureCounter = null)
|
|
||||||
{
|
|
||||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
||||||
_failureCounter = failureCounter ?? new NoOpAuditRedactionFailureCounter();
|
|
||||||
_regexCache = new AuditRegexCache(_logger);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public AuditEvent Apply(AuditEvent rawEvent)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var opts = _options.CurrentValue;
|
|
||||||
// Inbound API gets a dedicated, larger ceiling — request/response bodies are
|
|
||||||
// captured verbatim up to InboundMaxBytes (default 1 MiB) so support can
|
|
||||||
// replay exactly what the caller sent and what we returned. Other channels
|
|
||||||
// keep the global 8 KiB / 64 KiB policy.
|
|
||||||
// See docs/plans/2026-05-23-inbound-api-full-response-audit-design.md.
|
|
||||||
var cap = rawEvent.Channel == AuditChannel.ApiInbound
|
|
||||||
? opts.InboundMaxBytes
|
|
||||||
: (IsErrorStatus(rawEvent.Status) ? opts.ErrorCapBytes : opts.DefaultCapBytes);
|
|
||||||
|
|
||||||
// --- Header-redaction stage (runs BEFORE truncation) ----------
|
|
||||||
var request = RedactHeaders(rawEvent.RequestSummary, opts.HeaderRedactList);
|
|
||||||
var response = RedactHeaders(rawEvent.ResponseSummary, opts.HeaderRedactList);
|
|
||||||
var errorDetail = rawEvent.ErrorDetail;
|
|
||||||
var extra = rawEvent.Extra;
|
|
||||||
|
|
||||||
// --- Body-regex stage (also runs BEFORE truncation) -----------
|
|
||||||
// Resolves the active regex set per event so per-target overrides
|
|
||||||
// bound to AuditEvent.Target are picked up; effectively a no-op
|
|
||||||
// when neither GlobalBodyRedactors nor the per-target additions
|
|
||||||
// are configured.
|
|
||||||
var bodyRegexes = ResolveBodyRegexes(opts, rawEvent.Target);
|
|
||||||
if (bodyRegexes.Count > 0)
|
|
||||||
{
|
|
||||||
request = RedactBody(request, bodyRegexes);
|
|
||||||
response = RedactBody(response, bodyRegexes);
|
|
||||||
errorDetail = RedactBody(errorDetail, bodyRegexes);
|
|
||||||
extra = RedactBody(extra, bodyRegexes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- SQL parameter redaction stage (DbOutbound only) ----------
|
|
||||||
// Parses the M4 AuditingDbCommand RequestSummary shape
|
|
||||||
// {"sql":"...","parameters":{...}} and redacts parameter VALUES
|
|
||||||
// whose NAME matches the per-connection regex. Opt-in: no
|
|
||||||
// PerTargetOverrides[connectionName].RedactSqlParamsMatching =>
|
|
||||||
// no-op. Channel-guarded so the same regex can never accidentally
|
|
||||||
// touch an ApiOutbound row.
|
|
||||||
if (rawEvent.Channel == AuditChannel.DbOutbound
|
|
||||||
&& TryGetSqlParamRedactor(opts, rawEvent.Target, out var sqlParamRegex))
|
|
||||||
{
|
|
||||||
request = RedactSqlParameters(request, sqlParamRegex!);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Truncation stage -----------------------------------------
|
|
||||||
var truncated = false;
|
|
||||||
request = TruncateField(request, cap, ref truncated);
|
|
||||||
response = TruncateField(response, cap, ref truncated);
|
|
||||||
errorDetail = TruncateField(errorDetail, cap, ref truncated);
|
|
||||||
extra = TruncateField(extra, cap, ref truncated);
|
|
||||||
|
|
||||||
return rawEvent with
|
|
||||||
{
|
|
||||||
RequestSummary = request,
|
|
||||||
ResponseSummary = response,
|
|
||||||
ErrorDetail = errorDetail,
|
|
||||||
Extra = extra,
|
|
||||||
PayloadTruncated = rawEvent.PayloadTruncated || truncated,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// Audit is best-effort: over-redact rather than fail the caller.
|
|
||||||
// The per-stage try/catches above already handle redactor faults
|
|
||||||
// and increment the counter; this catch covers any unexpected
|
|
||||||
// surprise in the surrounding orchestration code.
|
|
||||||
_logger.LogWarning(
|
|
||||||
ex,
|
|
||||||
"Payload filter failed; returning raw event with PayloadTruncated=true");
|
|
||||||
try { _failureCounter.Increment(); } catch { /* swallow per §7 */ }
|
|
||||||
return rawEvent with { PayloadTruncated = true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parse <paramref name="json"/> as the documented
|
|
||||||
/// <c>{"headers": {...}, "body": ...}</c> shape and replace values whose
|
|
||||||
/// header NAME (case-insensitive) is in <paramref name="redactList"/> with
|
|
||||||
/// the redaction marker. Re-serialises and returns the result. Delegates to
|
|
||||||
/// <see cref="AuditRedactionPrimitives.RedactHeaders"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// No-op pass-through for inputs that aren't JSON-shaped — emitters that
|
|
||||||
/// have not yet adopted the convention (the M2 site emitters today, which
|
|
||||||
/// leave RequestSummary null on outbound API calls) get a transparent
|
|
||||||
/// pass. If the redactor itself throws, we over-redact the whole field
|
|
||||||
/// with the redactor-error marker and bump the failure counter.
|
|
||||||
/// </remarks>
|
|
||||||
private string? RedactHeaders(string? json, IList<string> redactList)
|
|
||||||
=> AuditRedactionPrimitives.RedactHeaders(json, redactList, _logger, IncrementFailureCounter);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Combine the global and per-target body-redactor lists for a single
|
|
||||||
/// event, returning the compiled-regex set to apply. Patterns that failed
|
|
||||||
/// compilation are silently skipped — the compile-time failure was logged
|
|
||||||
/// once on first encounter; we never let one bad pattern starve the rest.
|
|
||||||
/// </summary>
|
|
||||||
private IReadOnlyList<Regex> ResolveBodyRegexes(AuditLogOptions opts, string? target)
|
|
||||||
{
|
|
||||||
var hasGlobal = opts.GlobalBodyRedactors is { Count: > 0 };
|
|
||||||
var perTargetAdditions = (target != null
|
|
||||||
&& opts.PerTargetOverrides.TryGetValue(target, out var over)
|
|
||||||
&& over.AdditionalBodyRedactors is { Count: > 0 })
|
|
||||||
? over.AdditionalBodyRedactors
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!hasGlobal && perTargetAdditions == null)
|
|
||||||
{
|
|
||||||
return Array.Empty<Regex>();
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = new List<Regex>();
|
|
||||||
if (hasGlobal)
|
|
||||||
{
|
|
||||||
foreach (var pattern in opts.GlobalBodyRedactors)
|
|
||||||
{
|
|
||||||
if (_regexCache.TryGet(pattern, out var rx))
|
|
||||||
{
|
|
||||||
result.Add(rx!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (perTargetAdditions != null)
|
|
||||||
{
|
|
||||||
foreach (var pattern in perTargetAdditions)
|
|
||||||
{
|
|
||||||
if (_regexCache.TryGet(pattern, out var rx))
|
|
||||||
{
|
|
||||||
result.Add(rx!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Apply each compiled body-redactor regex to <paramref name="value"/> in
|
|
||||||
/// turn, replacing every match with the redaction marker. If any single
|
|
||||||
/// regex match throws (most commonly
|
|
||||||
/// <see cref="RegexMatchTimeoutException"/>) the field is over-redacted with
|
|
||||||
/// the redactor-error marker and the failure counter is incremented — the
|
|
||||||
/// user-facing action is never aborted. Delegates to
|
|
||||||
/// <see cref="AuditRedactionPrimitives.RedactBody"/>.
|
|
||||||
/// </summary>
|
|
||||||
private string? RedactBody(string? value, IReadOnlyList<Regex> regexes)
|
|
||||||
=> AuditRedactionPrimitives.RedactBody(value, regexes, _logger, IncrementFailureCounter);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Resolve the per-connection SQL parameter redaction regex for the given
|
|
||||||
/// DbOutbound event target. Target shape (M4 AuditingDbCommand): the
|
|
||||||
/// connection name optionally followed by <c>.<sql-snippet></c> for
|
|
||||||
/// disambiguation; the per-target dictionary is keyed by the connection
|
|
||||||
/// name alone, so we strip the snippet suffix before lookup. Patterns are
|
|
||||||
/// compiled with case-insensitive matching to match the documented
|
|
||||||
/// behaviour.
|
|
||||||
/// </summary>
|
|
||||||
private bool TryGetSqlParamRedactor(AuditLogOptions opts, string? target, out Regex? regex)
|
|
||||||
{
|
|
||||||
regex = null;
|
|
||||||
if (string.IsNullOrEmpty(target))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var dot = target.IndexOf('.');
|
|
||||||
var connectionKey = dot < 0 ? target : target[..dot];
|
|
||||||
|
|
||||||
if (!opts.PerTargetOverrides.TryGetValue(connectionKey, out var over)
|
|
||||||
|| string.IsNullOrEmpty(over.RedactSqlParamsMatching))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force case-insensitivity per the spec — even if the operator wrote
|
|
||||||
// the pattern without an IgnoreCase flag. The compile cache key folds
|
|
||||||
// the option to keep the entries unambiguous.
|
|
||||||
var cacheKey = "(?i)" + over.RedactSqlParamsMatching;
|
|
||||||
if (!_regexCache.TryGet(cacheKey, out regex))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Walk the M4 <c>{"sql":"...","parameters":{...}}</c> RequestSummary
|
|
||||||
/// shape; for each parameter whose NAME matches
|
|
||||||
/// <paramref name="paramNameRegex"/>, replace its value with the redaction
|
|
||||||
/// marker. Re-serialise. Delegates to
|
|
||||||
/// <see cref="AuditRedactionPrimitives.RedactSqlParameters"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// No-op pass-through when the input isn't parseable JSON, isn't a JSON
|
|
||||||
/// object, or doesn't carry a top-level <c>"parameters"</c> object. On
|
|
||||||
/// any unexpected fault the field is over-redacted and the failure counter
|
|
||||||
/// is bumped.
|
|
||||||
/// </remarks>
|
|
||||||
private string? RedactSqlParameters(string? json, Regex paramNameRegex)
|
|
||||||
=> AuditRedactionPrimitives.RedactSqlParameters(json, paramNameRegex, _logger, IncrementFailureCounter);
|
|
||||||
|
|
||||||
private static string? TruncateField(string? value, int cap, ref bool truncated)
|
|
||||||
=> AuditRedactionPrimitives.TruncateField(value, cap, ref truncated);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Bumps the injected redaction-failure counter, swallowing any fault per
|
|
||||||
/// alog.md §7 (a counter failure must never abort the audited action).
|
|
||||||
/// Passed as the <c>onFailure</c> callback to the shared primitives.
|
|
||||||
/// </summary>
|
|
||||||
private void IncrementFailureCounter()
|
|
||||||
{
|
|
||||||
try { _failureCounter.Increment(); } catch { /* swallow per §7 */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsErrorStatus(AuditStatus status) => status switch
|
|
||||||
{
|
|
||||||
AuditStatus.Delivered or AuditStatus.Submitted or AuditStatus.Forwarded => false,
|
|
||||||
_ => true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Filters an <see cref="AuditEvent"/> between construction and persistence —
|
|
||||||
/// truncates oversized payload fields, applies header/body/SQL-parameter
|
|
||||||
/// redaction, sets <see cref="AuditEvent.PayloadTruncated"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// Pure function: returns a filtered COPY of the input via <c>with</c>
|
|
||||||
/// expressions; never throws (over-redacts on internal failure and increments
|
|
||||||
/// the <c>AuditRedactionFailure</c> health metric).
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Wired in M5 between event construction and the writer chain
|
|
||||||
/// (<c>FallbackAuditWriter.WriteAsync</c>, <c>CentralAuditWriter.WriteAsync</c>,
|
|
||||||
/// and the <c>AuditLogIngestActor</c> handlers).
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
public interface IAuditPayloadFilter
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Apply the configured truncation + redaction policy to <paramref name="rawEvent"/>
|
|
||||||
/// and return a filtered copy. MUST NOT throw — on internal failure, over-redact
|
|
||||||
/// and surface the failure via the audit-redaction-failure health metric.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="rawEvent">The unfiltered audit event to process.</param>
|
|
||||||
AuditEvent Apply(AuditEvent rawEvent);
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
using System.Text.RegularExpressions;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// AuditLog-008: minimal always-safe fallback filter used by the writer chain
|
|
||||||
/// when no <see cref="IAuditPayloadFilter"/> is injected (test composition
|
|
||||||
/// roots, future composition roots that bypass <c>AddAuditLog</c>). Performs
|
|
||||||
/// HTTP header redaction for the always-sensitive defaults
|
|
||||||
/// (Authorization, X-Api-Key, Cookie, Set-Cookie) so a fixture that wires a
|
|
||||||
/// real <see cref="AuditEvent.RequestSummary"/> never persists those headers
|
|
||||||
/// in cleartext. Does NOT perform body-regex redaction, SQL-parameter
|
|
||||||
/// redaction, or truncation — those stages need
|
|
||||||
/// <see cref="DefaultAuditPayloadFilter"/> with live options. The contract is:
|
|
||||||
/// over-redact safely, never throw, never miss a header that's on the
|
|
||||||
/// default sensitive list.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class SafeDefaultAuditPayloadFilter : IAuditPayloadFilter
|
|
||||||
{
|
|
||||||
/// <summary>Singleton instance — the filter is stateless and side-effect-free.</summary>
|
|
||||||
public static SafeDefaultAuditPayloadFilter Instance { get; } = new SafeDefaultAuditPayloadFilter();
|
|
||||||
|
|
||||||
private static readonly string[] DefaultHeaderRedactList =
|
|
||||||
{
|
|
||||||
"Authorization",
|
|
||||||
"X-Api-Key",
|
|
||||||
"Cookie",
|
|
||||||
"Set-Cookie",
|
|
||||||
};
|
|
||||||
|
|
||||||
private static readonly Regex HeaderRegex = new(
|
|
||||||
@"(?<name>[A-Za-z][A-Za-z0-9\-_]*)\s*:\s*(?<value>[^\r\n]*)",
|
|
||||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
|
||||||
|
|
||||||
private SafeDefaultAuditPayloadFilter() { }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public AuditEvent Apply(AuditEvent rawEvent)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(rawEvent);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return rawEvent with
|
|
||||||
{
|
|
||||||
RequestSummary = RedactHeaders(rawEvent.RequestSummary),
|
|
||||||
ResponseSummary = RedactHeaders(rawEvent.ResponseSummary),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Over-redact: drop both summaries entirely so a malformed parse
|
|
||||||
// path never leaks the original. The contract is "never throw."
|
|
||||||
return rawEvent with
|
|
||||||
{
|
|
||||||
RequestSummary = "[redacted by SafeDefaultAuditPayloadFilter]",
|
|
||||||
ResponseSummary = "[redacted by SafeDefaultAuditPayloadFilter]",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? RedactHeaders(string? summary)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(summary)) return summary;
|
|
||||||
|
|
||||||
return HeaderRegex.Replace(summary, m =>
|
|
||||||
{
|
|
||||||
var name = m.Groups["name"].Value;
|
|
||||||
foreach (var sensitive in DefaultHeaderRedactList)
|
|
||||||
{
|
|
||||||
if (string.Equals(name, sensitive, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return $"{name}: [REDACTED]";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m.Value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,13 +3,16 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.Configuration;
|
using ZB.MOM.WW.Configuration;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
|
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog;
|
||||||
|
|
||||||
@@ -69,14 +72,15 @@ public static class ServiceCollectionExtensions
|
|||||||
// validator (a strict improvement over the previous AddSingleton).
|
// validator (a strict improvement over the previous AddSingleton).
|
||||||
services.AddValidatedOptions<AuditLogOptions, AuditLogOptionsValidator>(config, ConfigSectionName);
|
services.AddValidatedOptions<AuditLogOptions, AuditLogOptionsValidator>(config, ConfigSectionName);
|
||||||
|
|
||||||
// M5 Bundle A: payload filter — truncates oversized RequestSummary /
|
// C3 (Task 2.5): the canonical IAuditRedactor replaces the legacy
|
||||||
// ResponseSummary / ErrorDetail / Extra fields between event
|
// IAuditPayloadFilter in the writer pipeline. ScadaBridgeAuditRedactor
|
||||||
// construction and persistence. Bundle B layers header / body /
|
// is the port of DefaultAuditPayloadFilter onto the canonical record +
|
||||||
// SQL-parameter redaction onto the same singleton; Bundle C wires it
|
// its DetailsJson payload bag — same truncation + header / body /
|
||||||
// into the FallbackAuditWriter / CentralAuditWriter / IngestActor
|
// SQL-parameter redaction, applied between event construction and
|
||||||
// paths. Singleton — the filter is stateless and the IOptionsMonitor
|
// persistence. Singleton — stateless; the IOptionsMonitor dependency
|
||||||
// dependency picks up M5-T8 hot reloads on its own.
|
// picks up hot reloads on its own. The old IAuditPayloadFilter classes
|
||||||
services.AddSingleton<IAuditPayloadFilter, DefaultAuditPayloadFilter>();
|
// are retained but no longer wired into any pipeline (C7 deletes them).
|
||||||
|
services.AddSingleton<IAuditRedactor, ScadaBridgeAuditRedactor>();
|
||||||
|
|
||||||
// M5 Bundle B: per-stage redactor-failure counter. NoOp default;
|
// M5 Bundle B: per-stage redactor-failure counter. NoOp default;
|
||||||
// Bundle C replaces this binding with the Site Health Monitoring
|
// Bundle C replaces this binding with the Site Health Monitoring
|
||||||
@@ -115,7 +119,7 @@ public static class ServiceCollectionExtensions
|
|||||||
// The script-thread surface is FallbackAuditWriter (primary + ring +
|
// The script-thread surface is FallbackAuditWriter (primary + ring +
|
||||||
// counter), not the raw SqliteAuditWriter — primary failures must NEVER
|
// counter), not the raw SqliteAuditWriter — primary failures must NEVER
|
||||||
// abort the user-facing action.
|
// abort the user-facing action.
|
||||||
// Bundle C (M5-T6): the IAuditPayloadFilter singleton above is wired
|
// C3 (Task 2.5): the canonical IAuditRedactor singleton above is wired
|
||||||
// through the factory so every event written through this surface is
|
// through the factory so every event written through this surface is
|
||||||
// truncated + redacted before it hits SQLite (and the ring on
|
// truncated + redacted before it hits SQLite (and the ring on
|
||||||
// failure).
|
// failure).
|
||||||
@@ -124,7 +128,7 @@ public static class ServiceCollectionExtensions
|
|||||||
ring: sp.GetRequiredService<RingBufferFallback>(),
|
ring: sp.GetRequiredService<RingBufferFallback>(),
|
||||||
failureCounter: sp.GetRequiredService<IAuditWriteFailureCounter>(),
|
failureCounter: sp.GetRequiredService<IAuditWriteFailureCounter>(),
|
||||||
logger: sp.GetRequiredService<ILogger<FallbackAuditWriter>>(),
|
logger: sp.GetRequiredService<ILogger<FallbackAuditWriter>>(),
|
||||||
filter: sp.GetRequiredService<IAuditPayloadFilter>()));
|
redactor: sp.GetRequiredService<IAuditRedactor>()));
|
||||||
|
|
||||||
// ISiteStreamAuditClient: NoOp default. This binding remains correct for
|
// ISiteStreamAuditClient: NoOp default. This binding remains correct for
|
||||||
// central/test composition roots that have no SiteCommunicationActor.
|
// central/test composition roots that have no SiteCommunicationActor.
|
||||||
@@ -202,7 +206,7 @@ public static class ServiceCollectionExtensions
|
|||||||
// is intentionally distinct from IAuditWriter so site composition roots
|
// is intentionally distinct from IAuditWriter so site composition roots
|
||||||
// do not accidentally bind it; central composition roots that include
|
// do not accidentally bind it; central composition roots that include
|
||||||
// AddConfigurationDatabase get a working implementation transparently.
|
// AddConfigurationDatabase get a working implementation transparently.
|
||||||
// Bundle C (M5-T6): wire the IAuditPayloadFilter into the factory so
|
// C3 (Task 2.5): wire the canonical IAuditRedactor into the factory so
|
||||||
// NotificationOutboxActor + Inbound API rows are truncated + redacted
|
// NotificationOutboxActor + Inbound API rows are truncated + redacted
|
||||||
// before they hit MS SQL.
|
// before they hit MS SQL.
|
||||||
// M6 Bundle E (T8): also wire the ICentralAuditWriteFailureCounter
|
// M6 Bundle E (T8): also wire the ICentralAuditWriteFailureCounter
|
||||||
@@ -210,7 +214,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddSingleton<ICentralAuditWriter>(sp => new CentralAuditWriter(
|
services.AddSingleton<ICentralAuditWriter>(sp => new CentralAuditWriter(
|
||||||
sp,
|
sp,
|
||||||
sp.GetRequiredService<ILogger<CentralAuditWriter>>(),
|
sp.GetRequiredService<ILogger<CentralAuditWriter>>(),
|
||||||
sp.GetRequiredService<IAuditPayloadFilter>(),
|
sp.GetRequiredService<IAuditRedactor>(),
|
||||||
sp.GetRequiredService<ICentralAuditWriteFailureCounter>(),
|
sp.GetRequiredService<ICentralAuditWriteFailureCounter>(),
|
||||||
// SourceNode-stamping (Task 12): wire the local node identity so
|
// SourceNode-stamping (Task 12): wire the local node identity so
|
||||||
// central-origin rows (Notification Outbox dispatch, Inbound API)
|
// central-origin rows (Notification Outbox dispatch, Inbound API)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
|
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ public sealed class FallbackAuditWriter : IAuditWriter
|
|||||||
private readonly RingBufferFallback _ring;
|
private readonly RingBufferFallback _ring;
|
||||||
private readonly IAuditWriteFailureCounter _failureCounter;
|
private readonly IAuditWriteFailureCounter _failureCounter;
|
||||||
private readonly ILogger<FallbackAuditWriter> _logger;
|
private readonly ILogger<FallbackAuditWriter> _logger;
|
||||||
private readonly IAuditPayloadFilter _filter;
|
private readonly IAuditRedactor _redactor;
|
||||||
private readonly SemaphoreSlim _drainGate = new(1, 1);
|
private readonly SemaphoreSlim _drainGate = new(1, 1);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -48,26 +49,28 @@ public sealed class FallbackAuditWriter : IAuditWriter
|
|||||||
/// <param name="ring">Drop-oldest ring buffer used to stash events when the primary fails.</param>
|
/// <param name="ring">Drop-oldest ring buffer used to stash events when the primary fails.</param>
|
||||||
/// <param name="failureCounter">Counter incremented on each primary failure for health reporting.</param>
|
/// <param name="failureCounter">Counter incremented on each primary failure for health reporting.</param>
|
||||||
/// <param name="logger">Logger for diagnostics.</param>
|
/// <param name="logger">Logger for diagnostics.</param>
|
||||||
/// <param name="filter">Optional payload filter applied before writing; null means no filtering.</param>
|
/// <param name="redactor">Optional canonical redactor applied before writing; null means the always-safe default.</param>
|
||||||
public FallbackAuditWriter(
|
public FallbackAuditWriter(
|
||||||
IAuditWriter primary,
|
IAuditWriter primary,
|
||||||
RingBufferFallback ring,
|
RingBufferFallback ring,
|
||||||
IAuditWriteFailureCounter failureCounter,
|
IAuditWriteFailureCounter failureCounter,
|
||||||
ILogger<FallbackAuditWriter> logger,
|
ILogger<FallbackAuditWriter> logger,
|
||||||
IAuditPayloadFilter? filter = null)
|
IAuditRedactor? redactor = null)
|
||||||
{
|
{
|
||||||
_primary = primary ?? throw new ArgumentNullException(nameof(primary));
|
_primary = primary ?? throw new ArgumentNullException(nameof(primary));
|
||||||
_ring = ring ?? throw new ArgumentNullException(nameof(ring));
|
_ring = ring ?? throw new ArgumentNullException(nameof(ring));
|
||||||
_failureCounter = failureCounter ?? throw new ArgumentNullException(nameof(failureCounter));
|
_failureCounter = failureCounter ?? throw new ArgumentNullException(nameof(failureCounter));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
// AuditLog-008: never default to a null filter — over-redact instead.
|
// AuditLog-008: never default to a null redactor — over-redact instead.
|
||||||
// SafeDefaultAuditPayloadFilter.Instance performs HTTP header
|
// C3 (Task 2.5): the canonical IAuditRedactor replaces the legacy
|
||||||
|
// IAuditPayloadFilter. SafeDefaultAuditRedactor performs HTTP header
|
||||||
// redaction with the hard-coded sensitive defaults (Authorization,
|
// redaction with the hard-coded sensitive defaults (Authorization,
|
||||||
// X-Api-Key, Cookie, Set-Cookie) so a test composition root that
|
// X-Api-Key, Cookie, Set-Cookie) on the DetailsJson summaries so a test
|
||||||
// doesn't bind the real options never persists those headers
|
// composition root that doesn't bind the real options never persists
|
||||||
// verbatim. The real DefaultAuditPayloadFilter (truncation + body /
|
// those headers verbatim. The full ScadaBridgeAuditRedactor (truncation
|
||||||
// SQL-param redaction) is wired by AddAuditLog and takes precedence.
|
// + body / SQL-param redaction) is wired by AddAuditLog and takes
|
||||||
_filter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
|
// precedence.
|
||||||
|
_redactor = redactor ?? SafeDefaultAuditRedactor.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -75,14 +78,14 @@ public sealed class FallbackAuditWriter : IAuditWriter
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(evt);
|
ArgumentNullException.ThrowIfNull(evt);
|
||||||
|
|
||||||
// Filter once, up-front. The filtered event flows BOTH to the primary
|
// Redact once, up-front. The redacted event flows BOTH to the primary
|
||||||
// and (on failure) to the ring buffer — so a primary outage that
|
// and (on failure) to the ring buffer — so a primary outage that
|
||||||
// drains later still hands the SqliteAuditWriter a row that has
|
// drains later still hands the SqliteAuditWriter a row that has
|
||||||
// already been truncated and redacted. The filter contract is
|
// already been truncated and redacted. The redactor contract is
|
||||||
// "MUST NOT throw". AuditLog-008: _filter is now non-null (defaults
|
// "MUST NOT throw". AuditLog-008: _redactor is now non-null (defaults
|
||||||
// to SafeDefaultAuditPayloadFilter so header redaction is always
|
// to SafeDefaultAuditRedactor so header redaction is always applied
|
||||||
// applied even in composition roots that don't wire the real filter).
|
// even in composition roots that don't wire the real redactor).
|
||||||
var filtered = _filter.Apply(evt);
|
var filtered = _redactor.Apply(evt);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ using System.Threading.Channels;
|
|||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||||
|
|
||||||
@@ -236,14 +237,10 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(evt);
|
ArgumentNullException.ThrowIfNull(evt);
|
||||||
|
|
||||||
// Site rows always carry a non-null ForwardState; central rows leave it
|
// C3 transitional shim: the canonical record carries no ForwardState
|
||||||
// null. Force Pending on enqueue so callers can pass a bare AuditEvent
|
// (a site-storage-only concern). Site rows always start Pending; the
|
||||||
// without thinking about site-vs-central provenance.
|
// forwarding columns + queries are unchanged from the 24-column schema.
|
||||||
var siteEvt = evt.ForwardState is null
|
var pending = new PendingAuditEvent(evt, AuditForwardState.Pending);
|
||||||
? evt with { ForwardState = AuditForwardState.Pending }
|
|
||||||
: evt;
|
|
||||||
|
|
||||||
var pending = new PendingAuditEvent(siteEvt);
|
|
||||||
|
|
||||||
// CreateBounded(FullMode=Wait) means WriteAsync will await room rather
|
// CreateBounded(FullMode=Wait) means WriteAsync will await room rather
|
||||||
// than throw when full — exactly the hot-path back-pressure semantics
|
// than throw when full — exactly the hot-path back-pressure semantics
|
||||||
@@ -360,13 +357,18 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
|
|
||||||
foreach (var pending in batch)
|
foreach (var pending in batch)
|
||||||
{
|
{
|
||||||
var e = pending.Event;
|
// C3 transitional shim: decompose the canonical record into
|
||||||
pEventId.Value = e.EventId.ToString();
|
// the typed 24-column values the existing SQLite schema
|
||||||
pOccurredAt.Value = e.OccurredAtUtc.ToString("o");
|
// expects (Channel/Kind/Status + the DetailsJson domain
|
||||||
pChannel.Value = e.Channel.ToString();
|
// fields). ForwardState rides alongside the canonical record
|
||||||
pKind.Value = e.Kind.ToString();
|
// (site-storage-only) and is bound from pending.ForwardState.
|
||||||
pCorrelationId.Value = (object?)e.CorrelationId?.ToString() ?? DBNull.Value;
|
var r = AuditRowProjection.Decompose(pending.Event);
|
||||||
pSourceSiteId.Value = (object?)e.SourceSiteId ?? DBNull.Value;
|
pEventId.Value = r.EventId.ToString();
|
||||||
|
pOccurredAt.Value = r.OccurredAtUtc.ToString("o");
|
||||||
|
pChannel.Value = r.Channel.ToString();
|
||||||
|
pKind.Value = r.Kind.ToString();
|
||||||
|
pCorrelationId.Value = (object?)r.CorrelationId?.ToString() ?? DBNull.Value;
|
||||||
|
pSourceSiteId.Value = (object?)r.SourceSiteId ?? DBNull.Value;
|
||||||
// SourceNode-stamping: caller-provided value wins (preserves
|
// SourceNode-stamping: caller-provided value wins (preserves
|
||||||
// rows reconciled in from other nodes via the same writer);
|
// rows reconciled in from other nodes via the same writer);
|
||||||
// otherwise stamp from the local INodeIdentityProvider. The
|
// otherwise stamp from the local INodeIdentityProvider. The
|
||||||
@@ -374,24 +376,24 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
// time only. If the provider also returns null (unconfigured
|
// time only. If the provider also returns null (unconfigured
|
||||||
// node), the row's SourceNode stays NULL — operators see
|
// node), the row's SourceNode stays NULL — operators see
|
||||||
// "needs config" via the schema, not a magic fallback string.
|
// "needs config" via the schema, not a magic fallback string.
|
||||||
var sourceNode = e.SourceNode ?? _nodeIdentity.NodeName;
|
var sourceNode = r.SourceNode ?? _nodeIdentity.NodeName;
|
||||||
pSourceNode.Value = (object?)sourceNode ?? DBNull.Value;
|
pSourceNode.Value = (object?)sourceNode ?? DBNull.Value;
|
||||||
pSourceInstanceId.Value = (object?)e.SourceInstanceId ?? DBNull.Value;
|
pSourceInstanceId.Value = (object?)r.SourceInstanceId ?? DBNull.Value;
|
||||||
pSourceScript.Value = (object?)e.SourceScript ?? DBNull.Value;
|
pSourceScript.Value = (object?)r.SourceScript ?? DBNull.Value;
|
||||||
pActor.Value = (object?)e.Actor ?? DBNull.Value;
|
pActor.Value = (object?)r.Actor ?? DBNull.Value;
|
||||||
pTarget.Value = (object?)e.Target ?? DBNull.Value;
|
pTarget.Value = (object?)r.Target ?? DBNull.Value;
|
||||||
pStatus.Value = e.Status.ToString();
|
pStatus.Value = r.Status.ToString();
|
||||||
pHttpStatus.Value = (object?)e.HttpStatus ?? DBNull.Value;
|
pHttpStatus.Value = (object?)r.HttpStatus ?? DBNull.Value;
|
||||||
pDurationMs.Value = (object?)e.DurationMs ?? DBNull.Value;
|
pDurationMs.Value = (object?)r.DurationMs ?? DBNull.Value;
|
||||||
pErrorMessage.Value = (object?)e.ErrorMessage ?? DBNull.Value;
|
pErrorMessage.Value = (object?)r.ErrorMessage ?? DBNull.Value;
|
||||||
pErrorDetail.Value = (object?)e.ErrorDetail ?? DBNull.Value;
|
pErrorDetail.Value = (object?)r.ErrorDetail ?? DBNull.Value;
|
||||||
pRequestSummary.Value = (object?)e.RequestSummary ?? DBNull.Value;
|
pRequestSummary.Value = (object?)r.RequestSummary ?? DBNull.Value;
|
||||||
pResponseSummary.Value = (object?)e.ResponseSummary ?? DBNull.Value;
|
pResponseSummary.Value = (object?)r.ResponseSummary ?? DBNull.Value;
|
||||||
pPayloadTruncated.Value = e.PayloadTruncated ? 1 : 0;
|
pPayloadTruncated.Value = r.PayloadTruncated ? 1 : 0;
|
||||||
pExtra.Value = (object?)e.Extra ?? DBNull.Value;
|
pExtra.Value = (object?)r.Extra ?? DBNull.Value;
|
||||||
pForwardState.Value = (e.ForwardState ?? AuditForwardState.Pending).ToString();
|
pForwardState.Value = pending.ForwardState.ToString();
|
||||||
pExecutionId.Value = (object?)e.ExecutionId?.ToString() ?? DBNull.Value;
|
pExecutionId.Value = (object?)r.ExecutionId?.ToString() ?? DBNull.Value;
|
||||||
pParentExecutionId.Value = (object?)e.ParentExecutionId?.ToString() ?? DBNull.Value;
|
pParentExecutionId.Value = (object?)r.ParentExecutionId?.ToString() ?? DBNull.Value;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -405,7 +407,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
// recorded under the first writer's payload.
|
// recorded under the first writer's payload.
|
||||||
_logger.LogDebug(ex,
|
_logger.LogDebug(ex,
|
||||||
"Duplicate EventId {EventId} swallowed by SqliteAuditWriter",
|
"Duplicate EventId {EventId} swallowed by SqliteAuditWriter",
|
||||||
e.EventId);
|
r.EventId);
|
||||||
pending.Completion.TrySetResult();
|
pending.Completion.TrySetResult();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -788,34 +790,36 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
|
|
||||||
private static AuditEvent MapRow(SqliteDataReader reader)
|
private static AuditEvent MapRow(SqliteDataReader reader)
|
||||||
{
|
{
|
||||||
return new AuditEvent
|
// C3 transitional shim: recompose the canonical record from the 24
|
||||||
{
|
// columns. The ForwardState column (ordinal 20) is read for the
|
||||||
EventId = Guid.Parse(reader.GetString(0)),
|
// schema's sake but NOT placed on the canonical record — it stays a
|
||||||
OccurredAtUtc = DateTime.Parse(reader.GetString(1),
|
// site-storage-only concern (the forwarding queries below own it).
|
||||||
|
return AuditRowProjection.Recompose(new AuditRowProjection.AuditRowValues(
|
||||||
|
EventId: Guid.Parse(reader.GetString(0)),
|
||||||
|
OccurredAtUtc: DateTime.Parse(reader.GetString(1),
|
||||||
System.Globalization.CultureInfo.InvariantCulture,
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
System.Globalization.DateTimeStyles.RoundtripKind),
|
System.Globalization.DateTimeStyles.RoundtripKind),
|
||||||
Channel = Enum.Parse<AuditChannel>(reader.GetString(2)),
|
IngestedAtUtc: null,
|
||||||
Kind = Enum.Parse<AuditKind>(reader.GetString(3)),
|
Channel: Enum.Parse<AuditChannel>(reader.GetString(2)),
|
||||||
CorrelationId = reader.IsDBNull(4) ? null : Guid.Parse(reader.GetString(4)),
|
Kind: Enum.Parse<AuditKind>(reader.GetString(3)),
|
||||||
SourceSiteId = reader.IsDBNull(5) ? null : reader.GetString(5),
|
Status: Enum.Parse<AuditStatus>(reader.GetString(11)),
|
||||||
SourceNode = reader.IsDBNull(6) ? null : reader.GetString(6),
|
CorrelationId: reader.IsDBNull(4) ? null : Guid.Parse(reader.GetString(4)),
|
||||||
SourceInstanceId = reader.IsDBNull(7) ? null : reader.GetString(7),
|
ExecutionId: reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)),
|
||||||
SourceScript = reader.IsDBNull(8) ? null : reader.GetString(8),
|
ParentExecutionId: reader.IsDBNull(22) ? null : Guid.Parse(reader.GetString(22)),
|
||||||
Actor = reader.IsDBNull(9) ? null : reader.GetString(9),
|
SourceSiteId: reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||||
Target = reader.IsDBNull(10) ? null : reader.GetString(10),
|
SourceNode: reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||||
Status = Enum.Parse<AuditStatus>(reader.GetString(11)),
|
SourceInstanceId: reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||||
HttpStatus = reader.IsDBNull(12) ? null : reader.GetInt32(12),
|
SourceScript: reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||||
DurationMs = reader.IsDBNull(13) ? null : reader.GetInt32(13),
|
Actor: reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||||
ErrorMessage = reader.IsDBNull(14) ? null : reader.GetString(14),
|
Target: reader.IsDBNull(10) ? null : reader.GetString(10),
|
||||||
ErrorDetail = reader.IsDBNull(15) ? null : reader.GetString(15),
|
HttpStatus: reader.IsDBNull(12) ? null : reader.GetInt32(12),
|
||||||
RequestSummary = reader.IsDBNull(16) ? null : reader.GetString(16),
|
DurationMs: reader.IsDBNull(13) ? null : reader.GetInt32(13),
|
||||||
ResponseSummary = reader.IsDBNull(17) ? null : reader.GetString(17),
|
ErrorMessage: reader.IsDBNull(14) ? null : reader.GetString(14),
|
||||||
PayloadTruncated = reader.GetInt32(18) != 0,
|
ErrorDetail: reader.IsDBNull(15) ? null : reader.GetString(15),
|
||||||
Extra = reader.IsDBNull(19) ? null : reader.GetString(19),
|
RequestSummary: reader.IsDBNull(16) ? null : reader.GetString(16),
|
||||||
ForwardState = Enum.Parse<AuditForwardState>(reader.GetString(20)),
|
ResponseSummary: reader.IsDBNull(17) ? null : reader.GetString(17),
|
||||||
ExecutionId = reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)),
|
PayloadTruncated: reader.GetInt32(18) != 0,
|
||||||
ParentExecutionId = reader.IsDBNull(22) ? null : Guid.Parse(reader.GetString(22)),
|
Extra: reader.IsDBNull(19) ? null : reader.GetString(19)));
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -898,15 +902,19 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
private sealed class PendingAuditEvent
|
private sealed class PendingAuditEvent
|
||||||
{
|
{
|
||||||
/// <summary>Initializes a new instance of the PendingAuditEvent class.</summary>
|
/// <summary>Initializes a new instance of the PendingAuditEvent class.</summary>
|
||||||
/// <param name="evt">The audit event to persist.</param>
|
/// <param name="evt">The canonical audit event to persist.</param>
|
||||||
public PendingAuditEvent(AuditEvent evt)
|
/// <param name="forwardState">Site-local forwarding state stored alongside the canonical row (C3 shim — not a canonical field).</param>
|
||||||
|
public PendingAuditEvent(AuditEvent evt, AuditForwardState forwardState)
|
||||||
{
|
{
|
||||||
Event = evt;
|
Event = evt;
|
||||||
|
ForwardState = forwardState;
|
||||||
Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The audit event to persist.</summary>
|
/// <summary>The canonical audit event to persist.</summary>
|
||||||
public AuditEvent Event { get; }
|
public AuditEvent Event { get; }
|
||||||
|
/// <summary>Site-local forwarding state for this row (C3 shim — bound to the ForwardState column).</summary>
|
||||||
|
public AuditForwardState ForwardState { get; }
|
||||||
/// <summary>Task completion source for write completion signaling.</summary>
|
/// <summary>Task completion source for write completion signaling.</summary>
|
||||||
public TaskCompletionSource Completion { get; }
|
public TaskCompletionSource Completion { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||||
@@ -141,37 +141,33 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
|
|||||||
var channel = ChannelStringToEnum(context.Channel);
|
var channel = ChannelStringToEnum(context.Channel);
|
||||||
|
|
||||||
return new CachedCallTelemetry(
|
return new CachedCallTelemetry(
|
||||||
Audit: new AuditEvent
|
Audit: ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
channel: channel,
|
||||||
EventId = Guid.NewGuid(),
|
kind: kind,
|
||||||
OccurredAtUtc = DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
|
status: status,
|
||||||
Channel = channel,
|
occurredAtUtc: DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
|
||||||
Kind = kind,
|
target: context.Target,
|
||||||
CorrelationId = context.TrackedOperationId.Value,
|
correlationId: context.TrackedOperationId.Value,
|
||||||
// Audit Log #23 (ExecutionId Task 4): the originating script
|
// Audit Log #23 (ExecutionId Task 4): the originating script
|
||||||
// execution's per-run correlation id, threaded through the S&F
|
// execution's per-run correlation id, threaded through the S&F
|
||||||
// buffer; null on rows buffered before Task 4 (back-compat).
|
// buffer; null on rows buffered before Task 4 (back-compat).
|
||||||
ExecutionId = context.ExecutionId,
|
executionId: context.ExecutionId,
|
||||||
// Audit Log #23 (ParentExecutionId Task 6): the spawning
|
// Audit Log #23 (ParentExecutionId Task 6): the spawning
|
||||||
// inbound-API request's ExecutionId, threaded through the S&F
|
// inbound-API request's ExecutionId, threaded through the S&F
|
||||||
// buffer alongside ExecutionId so the retry-loop cached rows
|
// buffer alongside ExecutionId so the retry-loop cached rows
|
||||||
// correlate back to the cross-execution chain. Null for a
|
// correlate back to the cross-execution chain. Null for a
|
||||||
// non-routed run and on rows buffered before Task 6.
|
// non-routed run and on rows buffered before Task 6.
|
||||||
ParentExecutionId = context.ParentExecutionId,
|
parentExecutionId: context.ParentExecutionId,
|
||||||
SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
|
sourceSiteId: string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
|
||||||
SourceInstanceId = context.SourceInstanceId,
|
sourceInstanceId: context.SourceInstanceId,
|
||||||
// Audit Log #23 (ExecutionId Task 4): SourceScript is now
|
// Audit Log #23 (ExecutionId Task 4): SourceScript is now
|
||||||
// threaded through the S&F buffer alongside ExecutionId — the
|
// threaded through the S&F buffer alongside ExecutionId — the
|
||||||
// retry-loop cached rows carry the same provenance the
|
// retry-loop cached rows carry the same provenance the
|
||||||
// script-side cached rows do. Null on pre-Task-4 buffered rows.
|
// script-side cached rows do. Null on pre-Task-4 buffered rows.
|
||||||
SourceScript = context.SourceScript,
|
sourceScript: context.SourceScript,
|
||||||
Target = context.Target,
|
httpStatus: httpStatus,
|
||||||
Status = status,
|
durationMs: context.DurationMs,
|
||||||
HttpStatus = httpStatus,
|
errorMessage: lastError),
|
||||||
DurationMs = context.DurationMs,
|
|
||||||
ErrorMessage = lastError,
|
|
||||||
ForwardState = AuditForwardState.Pending,
|
|
||||||
},
|
|
||||||
Operational: new SiteCallOperational(
|
Operational: new SiteCallOperational(
|
||||||
TrackedOperationId: context.TrackedOperationId,
|
TrackedOperationId: context.TrackedOperationId,
|
||||||
Channel: context.Channel,
|
Channel: context.Channel,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||||
@@ -111,9 +111,11 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
|
|||||||
// FallbackAuditWriter) handles transient writer failures upstream;
|
// FallbackAuditWriter) handles transient writer failures upstream;
|
||||||
// a throw bubbling up here means the writer's own swallow contract
|
// a throw bubbling up here means the writer's own swallow contract
|
||||||
// failed, which is itself best-effort-handled.
|
// failed, which is itself best-effort-handled.
|
||||||
|
// C3: Kind/Status are domain fields carried in DetailsJson — decompose to log them.
|
||||||
|
var d = AuditRowProjection.Decompose(telemetry.Audit);
|
||||||
_logger.LogWarning(ex,
|
_logger.LogWarning(ex,
|
||||||
"CachedCallTelemetryForwarder: audit emission threw for EventId {EventId} (Kind {Kind}, Status {Status})",
|
"CachedCallTelemetryForwarder: audit emission threw for EventId {EventId} (Kind {Kind}, Status {Status})",
|
||||||
telemetry.Audit.EventId, telemetry.Audit.Kind, telemetry.Audit.Status);
|
d.EventId, d.Kind, d.Status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,9 +130,12 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// C3: the audit half's domain fields (Kind/SourceInstanceId/SourceScript)
|
||||||
|
// ride inside DetailsJson — decompose once for this packet.
|
||||||
|
var audit = AuditRowProjection.Decompose(telemetry.Audit);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
switch (telemetry.Audit.Kind)
|
switch (audit.Kind)
|
||||||
{
|
{
|
||||||
case AuditKind.CachedSubmit:
|
case AuditKind.CachedSubmit:
|
||||||
// Enqueue — insert-if-not-exists with the operational
|
// Enqueue — insert-if-not-exists with the operational
|
||||||
@@ -144,8 +149,8 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
|
|||||||
telemetry.Operational.TrackedOperationId,
|
telemetry.Operational.TrackedOperationId,
|
||||||
telemetry.Operational.Channel,
|
telemetry.Operational.Channel,
|
||||||
telemetry.Operational.Target,
|
telemetry.Operational.Target,
|
||||||
telemetry.Audit.SourceInstanceId,
|
audit.SourceInstanceId,
|
||||||
telemetry.Audit.SourceScript,
|
audit.SourceScript,
|
||||||
sourceNode: _nodeIdentity?.NodeName,
|
sourceNode: _nodeIdentity?.NodeName,
|
||||||
ct).ConfigureAwait(false);
|
ct).ConfigureAwait(false);
|
||||||
break;
|
break;
|
||||||
@@ -180,7 +185,7 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
|
|||||||
// forwarder.
|
// forwarder.
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"CachedCallTelemetryForwarder: unexpected audit kind {Kind} on tracking emission for EventId {EventId}",
|
"CachedCallTelemetryForwarder: unexpected audit kind {Kind} on tracking emission for EventId {EventId}",
|
||||||
telemetry.Audit.Kind, telemetry.Audit.EventId);
|
audit.Kind, audit.EventId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ using Akka.Actor;
|
|||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||||
@@ -259,8 +260,8 @@ public class SiteAuditTelemetryActor : ReceiveActor
|
|||||||
// row stays Pending (still not in emittedEventIds) and
|
// row stays Pending (still not in emittedEventIds) and
|
||||||
// central reconciliation will pick it up.
|
// central reconciliation will pick it up.
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"Cached-telemetry drain: audit row {EventId} ({Kind}) has no CorrelationId; skipping.",
|
"Cached-telemetry drain: audit row {EventId} ({Action}) has no CorrelationId; skipping.",
|
||||||
auditRow.EventId, auditRow.Kind);
|
auditRow.EventId, auditRow.Action);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,10 +364,13 @@ public class SiteAuditTelemetryActor : ReceiveActor
|
|||||||
private static CachedTelemetryPacket BuildCachedPacket(
|
private static CachedTelemetryPacket BuildCachedPacket(
|
||||||
AuditEvent auditRow, TrackingStatusSnapshot snapshot)
|
AuditEvent auditRow, TrackingStatusSnapshot snapshot)
|
||||||
{
|
{
|
||||||
var sourceSite = auditRow.SourceSiteId ?? string.Empty;
|
// C3: SourceSiteId + Channel ride inside the canonical record's
|
||||||
|
// DetailsJson — decompose to read them.
|
||||||
|
var audit = AuditRowProjection.Decompose(auditRow);
|
||||||
|
var sourceSite = audit.SourceSiteId ?? string.Empty;
|
||||||
// Channel string form mirrors the AuditChannel-to-string convention used
|
// Channel string form mirrors the AuditChannel-to-string convention used
|
||||||
// by SiteCallOperational + CachedCallLifecycleBridge.BuildPacket.
|
// by SiteCallOperational + CachedCallLifecycleBridge.BuildPacket.
|
||||||
var channelString = auditRow.Channel.ToString();
|
var channelString = audit.Channel.ToString();
|
||||||
var target = auditRow.Target ?? snapshot.TargetSummary ?? string.Empty;
|
var target = auditRow.Target ?? snapshot.TargetSummary ?? string.Empty;
|
||||||
|
|
||||||
var operationalDto = new SiteCallOperationalDto
|
var operationalDto = new SiteCallOperationalDto
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
||||||
|
|
||||||
@* Audit Log drilldown drawer (#23 M7 Bundle C / M7-T4..T8).
|
@* Audit Log drilldown drawer (#23 M7 Bundle C / M7-T4..T8).
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Child component for the central Audit Log page (#23 M7 Bundle C / M7-T4..T8).
|
/// Child component for the central Audit Log page (#23 M7 Bundle C / M7-T4..T8).
|
||||||
/// Renders one <see cref="AuditEvent"/> in a right-side off-canvas drawer.
|
/// Renders one <see cref="AuditEventView"/> in a right-side off-canvas drawer.
|
||||||
/// The drawer owns only the offcanvas chrome — backdrop, header, and the two
|
/// The drawer owns only the offcanvas chrome — backdrop, header, and the two
|
||||||
/// Close buttons; the single-row detail body (read-only fields, conditional
|
/// Close buttons; the single-row detail body (read-only fields, conditional
|
||||||
/// Error/Request/Response/Extra subsections, and action buttons) is delegated
|
/// Error/Request/Response/Extra subsections, and action buttons) is delegated
|
||||||
@@ -20,7 +20,7 @@ public partial class AuditDrilldownDrawer
|
|||||||
/// The row to render. When null the drawer renders nothing — the host
|
/// The row to render. When null the drawer renders nothing — the host
|
||||||
/// page uses this together with <see cref="IsOpen"/> to drive visibility.
|
/// page uses this together with <see cref="IsOpen"/> to drive visibility.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Parameter] public AuditEvent? Event { get; set; }
|
[Parameter] public AuditEventView? Event { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True when the host wants the drawer visible. We deliberately keep
|
/// True when the host wants the drawer visible. We deliberately keep
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
||||||
|
|
||||||
@* Reusable single-AuditEvent detail body (#23 M7 Bundle C / M7-T4..T8).
|
@* Reusable single-AuditEvent detail body (#23 M7 Bundle C / M7-T4..T8).
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using System.Text;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.JSInterop;
|
using Microsoft.JSInterop;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
|
||||||
@@ -66,7 +66,7 @@ public partial class AuditEventDetail
|
|||||||
/// The row to render. Required and non-null — the host (drawer or modal)
|
/// The row to render. Required and non-null — the host (drawer or modal)
|
||||||
/// only mounts this component once it has a row to show.
|
/// only mounts this component once it has a row to show.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Parameter, EditorRequired] public AuditEvent Event { get; set; } = null!;
|
[Parameter, EditorRequired] public AuditEventView Event { get; set; } = null!;
|
||||||
|
|
||||||
private const string RedactionSentinel = "<redacted>";
|
private const string RedactionSentinel = "<redacted>";
|
||||||
private const string RedactorErrorSentinel = "<redacted: redactor error>";
|
private const string RedactorErrorSentinel = "<redacted: redactor error>";
|
||||||
@@ -303,7 +303,7 @@ public partial class AuditEventDetail
|
|||||||
/// outbound audit rows — the audit pipeline does not always capture
|
/// outbound audit rows — the audit pipeline does not always capture
|
||||||
/// the verb explicitly.
|
/// the verb explicitly.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string BuildCurlCommand(AuditEvent ev)
|
private static string BuildCurlCommand(AuditEventView ev)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.Append("curl");
|
sb.Append("curl");
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
||||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
|
|
||||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
|
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
|
||||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
||||||
@inject IAuditLogQueryService QueryService
|
@inject IAuditLogQueryService QueryService
|
||||||
@@ -103,7 +102,7 @@
|
|||||||
return n.Length >= 8 ? n[..8] : n;
|
return n.Length >= 8 ? n[..8] : n;
|
||||||
}
|
}
|
||||||
|
|
||||||
private RenderFragment RenderCell(string key, AuditEvent row) => __builder =>
|
private RenderFragment RenderCell(string key, AuditEventView row) => __builder =>
|
||||||
{
|
{
|
||||||
switch (key)
|
switch (key)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.JSInterop;
|
using Microsoft.JSInterop;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
|
|||||||
private const string ColumnOrderStorageKey = "columnOrder";
|
private const string ColumnOrderStorageKey = "columnOrder";
|
||||||
private const string ColumnWidthsStorageKey = "columnWidths";
|
private const string ColumnWidthsStorageKey = "columnWidths";
|
||||||
|
|
||||||
private readonly List<AuditEvent> _rows = new();
|
private readonly List<AuditEventView> _rows = new();
|
||||||
private int _pageNumber = 1;
|
private int _pageNumber = 1;
|
||||||
private bool _loading;
|
private bool _loading;
|
||||||
private string? _error;
|
private string? _error;
|
||||||
@@ -109,9 +109,9 @@ public partial class AuditResultsGrid : IAsyncDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Raised when the user clicks a row. Bundle C wires this to the drilldown
|
/// Raised when the user clicks a row. Bundle C wires this to the drilldown
|
||||||
/// drawer. The event payload is the full <see cref="AuditEvent"/>.
|
/// drawer. The event payload is the full <see cref="AuditEventView"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Parameter] public EventCallback<AuditEvent> OnRowSelected { get; set; }
|
[Parameter] public EventCallback<AuditEventView> OnRowSelected { get; set; }
|
||||||
|
|
||||||
// Effective page size used when paging. Mirrors PageSize but bounded > 0.
|
// Effective page size used when paging. Mirrors PageSize but bounded > 0.
|
||||||
private int _pageSize => Math.Max(1, PageSize);
|
private int _pageSize => Math.Max(1, PageSize);
|
||||||
@@ -289,7 +289,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleRowClick(AuditEvent row)
|
private async Task HandleRowClick(AuditEventView row)
|
||||||
{
|
{
|
||||||
if (OnRowSelected.HasDelegate)
|
if (OnRowSelected.HasDelegate)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||||
|
|
||||||
@* Execution-Tree Node Detail Modal (Task 3).
|
@* Execution-Tree Node Detail Modal (Task 3).
|
||||||
Opened from an execution-tree node double-click. Given an ExecutionId it
|
Opened from an execution-tree node double-click. Given an ExecutionId it
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ using System.Globalization;
|
|||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.Web;
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
@@ -61,10 +60,10 @@ public partial class ExecutionDetailModal
|
|||||||
[Parameter] public EventCallback OnClose { get; set; }
|
[Parameter] public EventCallback OnClose { get; set; }
|
||||||
|
|
||||||
// The loaded rows for the current execution; empty until a load completes.
|
// The loaded rows for the current execution; empty until a load completes.
|
||||||
private IReadOnlyList<AuditEvent> _rows = Array.Empty<AuditEvent>();
|
private IReadOnlyList<AuditEventView> _rows = Array.Empty<AuditEventView>();
|
||||||
|
|
||||||
// The row whose detail is shown; null = list view.
|
// The row whose detail is shown; null = list view.
|
||||||
private AuditEvent? _selectedRow;
|
private AuditEventView? _selectedRow;
|
||||||
|
|
||||||
private bool _loading;
|
private bool _loading;
|
||||||
private string? _error;
|
private string? _error;
|
||||||
@@ -103,7 +102,7 @@ public partial class ExecutionDetailModal
|
|||||||
_loading = true;
|
_loading = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
_selectedRow = null;
|
_selectedRow = null;
|
||||||
_rows = Array.Empty<AuditEvent>();
|
_rows = Array.Empty<AuditEventView>();
|
||||||
|
|
||||||
if (ExecutionId is null)
|
if (ExecutionId is null)
|
||||||
{
|
{
|
||||||
@@ -135,7 +134,7 @@ public partial class ExecutionDetailModal
|
|||||||
// degrades the modal to an inline error banner rather than killing
|
// degrades the modal to an inline error banner rather than killing
|
||||||
// the SignalR circuit. Never rethrow.
|
// the SignalR circuit. Never rethrow.
|
||||||
_error = $"Could not load this execution's audit rows: {ex.Message}";
|
_error = $"Could not load this execution's audit rows: {ex.Message}";
|
||||||
_rows = Array.Empty<AuditEvent>();
|
_rows = Array.Empty<AuditEventView>();
|
||||||
_selectedRow = null;
|
_selectedRow = null;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -144,7 +143,7 @@ public partial class ExecutionDetailModal
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SelectRow(AuditEvent row) => _selectedRow = row;
|
private void SelectRow(AuditEventView row) => _selectedRow = row;
|
||||||
|
|
||||||
private void BackToList() => _selectedRow = null;
|
private void BackToList() => _selectedRow = null;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
|
||||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit
|
||||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
|
|
||||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
|
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
|
||||||
@using ZB.MOM.WW.ScadaBridge.Security
|
@using ZB.MOM.WW.ScadaBridge.Security
|
||||||
@inject IAuditLogQueryService AuditLogQueryService
|
@inject IAuditLogQueryService AuditLogQueryService
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ using System.Globalization;
|
|||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.Routing;
|
using Microsoft.AspNetCore.Components.Routing;
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ public partial class AuditLogPage : IDisposable
|
|||||||
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
||||||
|
|
||||||
private AuditLogQueryFilter? _currentFilter;
|
private AuditLogQueryFilter? _currentFilter;
|
||||||
private AuditEvent? _selectedEvent;
|
private AuditEventView? _selectedEvent;
|
||||||
private bool _drawerOpen;
|
private bool _drawerOpen;
|
||||||
private string? _initialInstanceSearch;
|
private string? _initialInstanceSearch;
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ public partial class AuditLogPage : IDisposable
|
|||||||
_currentFilter = filter;
|
_currentFilter = filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleRowSelected(AuditEvent row)
|
private void HandleRowSelected(AuditEventView row)
|
||||||
{
|
{
|
||||||
// Bundle C: a grid row click hands us the full AuditEvent. We pin it as
|
// Bundle C: a grid row click hands us the full AuditEvent. We pin it as
|
||||||
// the selected row and open the drilldown drawer — the drawer is fully
|
// the selected row and open the drilldown drawer — the drawer is fully
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flattened, typed view of a canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/> for the
|
||||||
|
/// Central UI audit pages. C3 (Task 2.5) made the canonical record the seam type — the
|
||||||
|
/// query service decomposes it into this view (via <see cref="AuditRowProjection"/>) so the
|
||||||
|
/// existing razor bindings (<c>row.Channel</c>, <c>Event.Status</c>, <c>evt.RequestSummary</c>,
|
||||||
|
/// …) keep working against typed properties rather than parsing <c>DetailsJson</c> inline.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is presentation-only: it carries the same field surface the bespoke
|
||||||
|
/// <c>Commons.Entities.Audit.AuditEvent</c> exposed before C3. <c>ForwardState</c> is always
|
||||||
|
/// null on the central read path (it is site-storage-only and not carried on canonical rows).
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record AuditEventView
|
||||||
|
{
|
||||||
|
/// <summary>Idempotency key.</summary>
|
||||||
|
public Guid EventId { get; init; }
|
||||||
|
/// <summary>UTC timestamp when the audited action occurred.</summary>
|
||||||
|
public DateTime OccurredAtUtc { get; init; }
|
||||||
|
/// <summary>UTC ingest timestamp (central-set); null until ingest.</summary>
|
||||||
|
public DateTime? IngestedAtUtc { get; init; }
|
||||||
|
/// <summary>Trust-boundary channel.</summary>
|
||||||
|
public AuditChannel Channel { get; init; }
|
||||||
|
/// <summary>Specific event kind.</summary>
|
||||||
|
public AuditKind Kind { get; init; }
|
||||||
|
/// <summary>Per-operation correlation id.</summary>
|
||||||
|
public Guid? CorrelationId { get; init; }
|
||||||
|
/// <summary>Originating execution id.</summary>
|
||||||
|
public Guid? ExecutionId { get; init; }
|
||||||
|
/// <summary>Spawning execution id; null for top-level runs.</summary>
|
||||||
|
public Guid? ParentExecutionId { get; init; }
|
||||||
|
/// <summary>Site id where the action originated.</summary>
|
||||||
|
public string? SourceSiteId { get; init; }
|
||||||
|
/// <summary>Cluster node that emitted the event.</summary>
|
||||||
|
public string? SourceNode { get; init; }
|
||||||
|
/// <summary>Instance id where the action originated.</summary>
|
||||||
|
public string? SourceInstanceId { get; init; }
|
||||||
|
/// <summary>Script that initiated the action.</summary>
|
||||||
|
public string? SourceScript { get; init; }
|
||||||
|
/// <summary>Authenticated actor.</summary>
|
||||||
|
public string? Actor { get; init; }
|
||||||
|
/// <summary>Target of the action.</summary>
|
||||||
|
public string? Target { get; init; }
|
||||||
|
/// <summary>Lifecycle status.</summary>
|
||||||
|
public AuditStatus Status { get; init; }
|
||||||
|
/// <summary>HTTP status code where applicable.</summary>
|
||||||
|
public int? HttpStatus { get; init; }
|
||||||
|
/// <summary>Duration of the action in ms.</summary>
|
||||||
|
public int? DurationMs { get; init; }
|
||||||
|
/// <summary>Human-readable error summary.</summary>
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
/// <summary>Verbose error detail.</summary>
|
||||||
|
public string? ErrorDetail { get; init; }
|
||||||
|
/// <summary>Truncated/redacted request summary.</summary>
|
||||||
|
public string? RequestSummary { get; init; }
|
||||||
|
/// <summary>Truncated/redacted response summary.</summary>
|
||||||
|
public string? ResponseSummary { get; init; }
|
||||||
|
/// <summary>True when summaries were truncated.</summary>
|
||||||
|
public bool PayloadTruncated { get; init; }
|
||||||
|
/// <summary>Free-form JSON extension.</summary>
|
||||||
|
public string? Extra { get; init; }
|
||||||
|
/// <summary>Site-local forwarding state; always null on the central read path.</summary>
|
||||||
|
public AuditForwardState? ForwardState { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decomposes a canonical <see cref="AuditEvent"/> into a flat view for the UI.
|
||||||
|
/// </summary>
|
||||||
|
public static AuditEventView From(AuditEvent evt)
|
||||||
|
{
|
||||||
|
var r = AuditRowProjection.Decompose(evt);
|
||||||
|
return new AuditEventView
|
||||||
|
{
|
||||||
|
EventId = r.EventId,
|
||||||
|
OccurredAtUtc = r.OccurredAtUtc,
|
||||||
|
IngestedAtUtc = r.IngestedAtUtc,
|
||||||
|
Channel = r.Channel,
|
||||||
|
Kind = r.Kind,
|
||||||
|
CorrelationId = r.CorrelationId,
|
||||||
|
ExecutionId = r.ExecutionId,
|
||||||
|
ParentExecutionId = r.ParentExecutionId,
|
||||||
|
SourceSiteId = r.SourceSiteId,
|
||||||
|
SourceNode = r.SourceNode,
|
||||||
|
SourceInstanceId = r.SourceInstanceId,
|
||||||
|
SourceScript = r.SourceScript,
|
||||||
|
Actor = r.Actor,
|
||||||
|
Target = r.Target,
|
||||||
|
Status = r.Status,
|
||||||
|
HttpStatus = r.HttpStatus,
|
||||||
|
DurationMs = r.DurationMs,
|
||||||
|
ErrorMessage = r.ErrorMessage,
|
||||||
|
ErrorDetail = r.ErrorDetail,
|
||||||
|
RequestSummary = r.RequestSummary,
|
||||||
|
ResponseSummary = r.ResponseSummary,
|
||||||
|
PayloadTruncated = r.PayloadTruncated,
|
||||||
|
Extra = r.Extra,
|
||||||
|
ForwardState = null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
|
|
||||||
@@ -121,7 +120,7 @@ public sealed class AuditLogExportService : IAuditLogExportService
|
|||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
await writer.WriteLineAsync(FormatCsvRow(evt));
|
await writer.WriteLineAsync(FormatCsvRow(AuditEventView.From(evt)));
|
||||||
written++;
|
written++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +139,9 @@ public sealed class AuditLogExportService : IAuditLogExportService
|
|||||||
var last = page[^1];
|
var last = page[^1];
|
||||||
cursor = new AuditLogPaging(
|
cursor = new AuditLogPaging(
|
||||||
PageSize: pageSize,
|
PageSize: pageSize,
|
||||||
AfterOccurredAtUtc: last.OccurredAtUtc,
|
// C3: canonical OccurredAtUtc is a DateTimeOffset; the keyset
|
||||||
|
// cursor column is a UTC DateTime.
|
||||||
|
AfterOccurredAtUtc: last.OccurredAtUtc.UtcDateTime,
|
||||||
AfterEventId: last.EventId);
|
AfterEventId: last.EventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,13 +170,13 @@ public sealed class AuditLogExportService : IAuditLogExportService
|
|||||||
"ResponseSummary,PayloadTruncated,Extra,ForwardState";
|
"ResponseSummary,PayloadTruncated,Extra,ForwardState";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Serialises one <see cref="AuditEvent"/> as a CSV row (no trailing newline).
|
/// Serialises one <see cref="AuditEventView"/> as a CSV row (no trailing newline).
|
||||||
/// Each nullable column renders as the empty string when null; non-null
|
/// Each nullable column renders as the empty string when null; non-null
|
||||||
/// scalars use invariant culture so an export taken on one locale parses
|
/// scalars use invariant culture so an export taken on one locale parses
|
||||||
/// cleanly on another.
|
/// cleanly on another.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="evt">The audit event to format as a CSV row.</param>
|
/// <param name="evt">The audit event view to format as a CSV row.</param>
|
||||||
internal static string FormatCsvRow(AuditEvent evt)
|
internal static string FormatCsvRow(AuditEventView evt)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder(256);
|
var sb = new StringBuilder(256);
|
||||||
AppendField(sb, evt.EventId.ToString(), first: true);
|
AppendField(sb, evt.EventId.ToString(), first: true);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
@@ -93,7 +92,7 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
|
|||||||
public int DefaultPageSize => 100;
|
public int DefaultPageSize => 100;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
public async Task<IReadOnlyList<AuditEventView>> QueryAsync(
|
||||||
AuditLogQueryFilter filter,
|
AuditLogQueryFilter filter,
|
||||||
AuditLogPaging? paging = null,
|
AuditLogPaging? paging = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
@@ -101,17 +100,22 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
|
|||||||
ArgumentNullException.ThrowIfNull(filter);
|
ArgumentNullException.ThrowIfNull(filter);
|
||||||
var effective = paging ?? new AuditLogPaging(DefaultPageSize);
|
var effective = paging ?? new AuditLogPaging(DefaultPageSize);
|
||||||
|
|
||||||
|
// C3 (Task 2.5): the repository seam returns canonical records; decompose
|
||||||
|
// each into a flat AuditEventView so the audit pages keep binding to typed
|
||||||
|
// properties.
|
||||||
// Test-seam ctor: use the injected repository directly.
|
// Test-seam ctor: use the injected repository directly.
|
||||||
if (_injectedRepository is not null)
|
if (_injectedRepository is not null)
|
||||||
{
|
{
|
||||||
return await _injectedRepository.QueryAsync(filter, effective, ct);
|
var rows = await _injectedRepository.QueryAsync(filter, effective, ct);
|
||||||
|
return rows.Select(AuditEventView.From).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Production: a fresh scope (and thus a fresh DbContext) per query so the
|
// Production: a fresh scope (and thus a fresh DbContext) per query so the
|
||||||
// page's auto-load never shares the circuit-scoped context.
|
// page's auto-load never shares the circuit-scoped context.
|
||||||
await using var scope = _scopeFactory!.CreateAsyncScope();
|
await using var scope = _scopeFactory!.CreateAsyncScope();
|
||||||
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||||
return await repository.QueryAsync(filter, effective, ct);
|
var result = await repository.QueryAsync(filter, effective, ct);
|
||||||
|
return result.Select(AuditEventView.From).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
|
|
||||||
@@ -18,13 +17,18 @@ public interface IAuditLogQueryService
|
|||||||
/// <paramref name="paging"/> is <c>null</c>, defaults to <see cref="DefaultPageSize"/>
|
/// <paramref name="paging"/> is <c>null</c>, defaults to <see cref="DefaultPageSize"/>
|
||||||
/// rows with no cursor (first page). The repository orders by
|
/// rows with no cursor (first page). The repository orders by
|
||||||
/// <c>(OccurredAtUtc DESC, EventId DESC)</c>; pass the last row's
|
/// <c>(OccurredAtUtc DESC, EventId DESC)</c>; pass the last row's
|
||||||
/// <see cref="AuditEvent.OccurredAtUtc"/> + <see cref="AuditEvent.EventId"/>
|
/// <see cref="AuditEventView.OccurredAtUtc"/> + <see cref="AuditEventView.EventId"/>
|
||||||
/// back as the cursor for the next page.
|
/// back as the cursor for the next page.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// C3 (Task 2.5): the repository seam returns the canonical
|
||||||
|
/// <c>ZB.MOM.WW.Audit.AuditEvent</c>; this facade decomposes each row into a flat
|
||||||
|
/// <see cref="AuditEventView"/> so the audit pages keep binding to typed properties.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="filter">Filter criteria applied to the audit log query.</param>
|
/// <param name="filter">Filter criteria applied to the audit log query.</param>
|
||||||
/// <param name="paging">Optional paging cursor; defaults to first page when null.</param>
|
/// <param name="paging">Optional paging cursor; defaults to first page when null.</param>
|
||||||
/// <param name="ct">Cancellation token.</param>
|
/// <param name="ct">Cancellation token.</param>
|
||||||
Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
Task<IReadOnlyList<AuditEventView>> QueryAsync(
|
||||||
AuditLogQueryFilter filter,
|
AuditLogQueryFilter filter,
|
||||||
AuditLogPaging? paging = null,
|
AuditLogPaging? paging = null,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Single source of truth for AuditLog (#23) rows. Central rows leave ForwardState null;
|
|
||||||
/// site rows leave IngestedAtUtc null until ingest. Append-only.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// All <c>*Utc</c>-suffixed <see cref="DateTime"/> properties on this record are
|
|
||||||
/// invariantly UTC ("All timestamps are UTC throughout the system." — CLAUDE.md).
|
|
||||||
/// Their init-setters call <see cref="DateTime.SpecifyKind(DateTime, DateTimeKind)"/>
|
|
||||||
/// to force <see cref="DateTimeKind.Utc"/> on assignment, so a value built from a
|
|
||||||
/// <c>DateTime</c> literal or re-hydrated from a SQL Server <c>datetime2</c> column
|
|
||||||
/// (which strips the <c>Kind</c> flag on the wire) cannot leak downstream as
|
|
||||||
/// <see cref="DateTimeKind.Unspecified"/> or be silently re-interpreted as local
|
|
||||||
/// time. The unrelated <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications"/>
|
|
||||||
/// surface uses <see cref="DateTimeOffset"/> for the same UTC guarantee; this
|
|
||||||
/// entity stays on <see cref="DateTime"/> to match the partitioned SQL Server
|
|
||||||
/// <c>datetime2</c> column shape required by the AuditLog table.
|
|
||||||
/// </remarks>
|
|
||||||
public sealed record AuditEvent
|
|
||||||
{
|
|
||||||
/// <summary>Idempotency key; uniquely identifies one audit lifecycle event.</summary>
|
|
||||||
public Guid EventId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// UTC timestamp when the audited action occurred at its source. The value
|
|
||||||
/// MUST be in UTC ("All timestamps are UTC throughout the system." — CLAUDE.md).
|
|
||||||
/// The init-setter forces <see cref="DateTimeKind.Utc"/> on assignment via
|
|
||||||
/// <see cref="DateTime.SpecifyKind(DateTime, DateTimeKind)"/>, so any
|
|
||||||
/// construction path that supplies a value with <see cref="DateTimeKind.Unspecified"/>
|
|
||||||
/// (e.g. a <c>DateTime</c> literal, JSON deserialisation, or a SQL Server
|
|
||||||
/// <c>datetime2</c> read where the value bypassed the EF converter) is
|
|
||||||
/// re-tagged as UTC rather than treated as local time downstream. Producers
|
|
||||||
/// are still expected to supply values that ARE genuinely UTC — the setter
|
|
||||||
/// only fixes the <c>Kind</c> flag, it cannot re-interpret a local-time value.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime OccurredAtUtc
|
|
||||||
{
|
|
||||||
get => _occurredAtUtc;
|
|
||||||
init => _occurredAtUtc = DateTime.SpecifyKind(value, DateTimeKind.Utc);
|
|
||||||
}
|
|
||||||
private readonly DateTime _occurredAtUtc;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// UTC timestamp when the row was ingested at central; null on the site hot-path.
|
|
||||||
/// The value MUST be in UTC when non-null; the init-setter forces
|
|
||||||
/// <see cref="DateTimeKind.Utc"/> on assignment, matching
|
|
||||||
/// <see cref="OccurredAtUtc"/>'s contract.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? IngestedAtUtc
|
|
||||||
{
|
|
||||||
get => _ingestedAtUtc;
|
|
||||||
init => _ingestedAtUtc = value.HasValue
|
|
||||||
? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc)
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
private readonly DateTime? _ingestedAtUtc;
|
|
||||||
|
|
||||||
/// <summary>Trust-boundary channel the audited action crossed.</summary>
|
|
||||||
public AuditChannel Channel { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Specific event kind within the channel (see alog.md §4).</summary>
|
|
||||||
public AuditKind Kind { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
|
|
||||||
public Guid? CorrelationId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Id of the originating script execution / inbound request — the universal
|
|
||||||
/// per-run correlation value, distinct from <see cref="CorrelationId"/> (which
|
|
||||||
/// is the per-operation lifecycle id).
|
|
||||||
/// </summary>
|
|
||||||
public Guid? ExecutionId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// <see cref="ExecutionId"/> of the execution that spawned this run, when this
|
|
||||||
/// run was spawned by another; null for top-level runs. Lets a spawned
|
|
||||||
/// execution point back at its spawner for cross-run correlation.
|
|
||||||
/// </summary>
|
|
||||||
public Guid? ParentExecutionId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Site id where the action originated; null for central-direct events.</summary>
|
|
||||||
public string? SourceSiteId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The cluster node on which the event was emitted — `node-a` / `node-b` for
|
|
||||||
/// site rows (qualified by <see cref="SourceSiteId"/>), `central-a` / `central-b`
|
|
||||||
/// for central-originated rows. Stamped by the writing node from
|
|
||||||
/// <c>INodeIdentityProvider</c>; nullable so reconciled rows from a node that
|
|
||||||
/// has since been retired don't block ingest.
|
|
||||||
/// </summary>
|
|
||||||
public string? SourceNode { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Instance id where the action originated, when applicable.</summary>
|
|
||||||
public string? SourceInstanceId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Script that initiated the action (script trust boundary), when applicable.</summary>
|
|
||||||
public string? SourceScript { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Authenticated actor for inbound paths (API key name, user, etc.).</summary>
|
|
||||||
public string? Actor { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Target of the action: external system name, db connection name, list name, or inbound method.</summary>
|
|
||||||
public string? Target { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Lifecycle status of this row.</summary>
|
|
||||||
public AuditStatus Status { get; init; }
|
|
||||||
|
|
||||||
/// <summary>HTTP status code where applicable (outbound API + inbound API).</summary>
|
|
||||||
public int? HttpStatus { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Duration of the audited action in milliseconds, when measurable.</summary>
|
|
||||||
public int? DurationMs { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Human-readable error summary on failure rows.</summary>
|
|
||||||
public string? ErrorMessage { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Verbose error detail (stack/exception) on failure rows.</summary>
|
|
||||||
public string? ErrorDetail { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Truncated/redacted request summary; capped per AuditLogOptions.</summary>
|
|
||||||
public string? RequestSummary { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Truncated/redacted response summary; capped per AuditLogOptions.</summary>
|
|
||||||
public string? ResponseSummary { get; init; }
|
|
||||||
|
|
||||||
/// <summary>True when Request/Response summaries were truncated to the payload cap.</summary>
|
|
||||||
public bool PayloadTruncated { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Free-form JSON extension column for channel-specific extras.</summary>
|
|
||||||
public string? Extra { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Site-local forwarding state; null on central rows.</summary>
|
|
||||||
public AuditForwardState? ForwardState { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
|
|
||||||
@@ -7,6 +7,12 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|||||||
/// Implementations on the site write to local SQLite hot-path; on central they write to MS SQL directly.
|
/// Implementations on the site write to local SQLite hot-path; on central they write to MS SQL directly.
|
||||||
/// Failures must NEVER abort the user-facing action.
|
/// Failures must NEVER abort the user-facing action.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// C3 (Task 2.5): the event type is the canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/>.
|
||||||
|
/// The local seam is retained (rather than collapsed onto <c>ZB.MOM.WW.Audit.IAuditWriter</c>)
|
||||||
|
/// so it stays a distinct DI binding from <see cref="ICentralAuditWriter"/> and so the many
|
||||||
|
/// existing site/central implementations and test fakes keep their identity.
|
||||||
|
/// </remarks>
|
||||||
public interface IAuditWriter
|
public interface IAuditWriter
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
@@ -34,7 +34,7 @@ public interface ISiteAuditQueue
|
|||||||
/// <see cref="MarkForwardedAsync"/> will yield the same rows again.
|
/// <see cref="MarkForwardedAsync"/> will yield the same rows again.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// AuditLog-001: cached-lifecycle <see cref="AuditEvent.Kind"/>s
|
/// AuditLog-001: cached-lifecycle audit kinds
|
||||||
/// (<see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.CachedSubmit"/>,
|
/// (<see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.CachedSubmit"/>,
|
||||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.ApiCallCached"/>,
|
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.ApiCallCached"/>,
|
||||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.DbWriteCached"/>,
|
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.DbWriteCached"/>,
|
||||||
@@ -52,7 +52,7 @@ public interface ISiteAuditQueue
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// AuditLog-001: returns up to <paramref name="limit"/> rows in
|
/// AuditLog-001: returns up to <paramref name="limit"/> rows in
|
||||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Pending"/>
|
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Pending"/>
|
||||||
/// whose <see cref="AuditEvent.Kind"/> belongs to the cached-call lifecycle
|
/// whose audit kind belongs to the cached-call lifecycle
|
||||||
/// vocabulary (<see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.CachedSubmit"/>,
|
/// vocabulary (<see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.CachedSubmit"/>,
|
||||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.ApiCallCached"/>,
|
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.ApiCallCached"/>,
|
||||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.DbWriteCached"/>,
|
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.DbWriteCached"/>,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transitional canonical ⇄ 24-column shim for the two AuditLog storage
|
||||||
|
/// implementations (site SQLite, central SQL Server). C3 keeps the existing
|
||||||
|
/// 24-column tables UNCHANGED; this helper decomposes a canonical
|
||||||
|
/// <see cref="ZB.MOM.WW.Audit.AuditEvent"/> into the typed domain values the
|
||||||
|
/// columns expect (Channel/Kind/Status enums + the <see cref="AuditDetails"/>
|
||||||
|
/// fields) and recomposes a canonical record from those column values.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// C3 of the ScadaBridge audit re-architecture (Task 2.5). The canonical record
|
||||||
|
/// only carries Action/Category/Outcome at the top level and stashes every
|
||||||
|
/// ScadaBridge domain field inside <c>DetailsJson</c>; the legacy storage rows
|
||||||
|
/// carry the domain fields as typed columns. This shim bridges the two without
|
||||||
|
/// any schema change. C4 replaces the site shim with the real DetailsJson
|
||||||
|
/// schema; C5 the central one.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>ForwardState</c> is deliberately NOT part of this projection — it is a
|
||||||
|
/// site-storage-only concern carried alongside the canonical record by the site
|
||||||
|
/// SQLite writer, never inside <c>DetailsJson</c> and never on a central row.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class AuditRowProjection
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The decomposed domain view of a canonical <see cref="AuditEvent"/> — the
|
||||||
|
/// values the 24 storage columns expect. Built by <see cref="Decompose"/> from
|
||||||
|
/// the canonical top-level fields plus the <see cref="AuditDetails"/> bag.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct AuditRowValues(
|
||||||
|
Guid EventId,
|
||||||
|
DateTime OccurredAtUtc,
|
||||||
|
DateTime? IngestedAtUtc,
|
||||||
|
AuditChannel Channel,
|
||||||
|
AuditKind Kind,
|
||||||
|
AuditStatus Status,
|
||||||
|
Guid? CorrelationId,
|
||||||
|
Guid? ExecutionId,
|
||||||
|
Guid? ParentExecutionId,
|
||||||
|
string? SourceSiteId,
|
||||||
|
string? SourceNode,
|
||||||
|
string? SourceInstanceId,
|
||||||
|
string? SourceScript,
|
||||||
|
string? Actor,
|
||||||
|
string? Target,
|
||||||
|
int? HttpStatus,
|
||||||
|
int? DurationMs,
|
||||||
|
string? ErrorMessage,
|
||||||
|
string? ErrorDetail,
|
||||||
|
string? RequestSummary,
|
||||||
|
string? ResponseSummary,
|
||||||
|
bool PayloadTruncated,
|
||||||
|
string? Extra);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decomposes a canonical record into the typed column values. Channel/Kind/Status
|
||||||
|
/// come from <c>DetailsJson</c> (the strings written by
|
||||||
|
/// <see cref="ScadaBridgeAuditEventFactory"/>); a missing/unparseable discriminator
|
||||||
|
/// falls back to the first enum member (defensive — production rows always carry them).
|
||||||
|
/// </summary>
|
||||||
|
public static AuditRowValues Decompose(AuditEvent evt)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(evt);
|
||||||
|
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson);
|
||||||
|
|
||||||
|
var channel = ParseEnum(d.Channel, AuditChannel.ApiInbound);
|
||||||
|
var kind = ParseEnum(d.Kind, AuditKind.InboundRequest);
|
||||||
|
var status = ParseEnum(d.Status, AuditStatus.Submitted);
|
||||||
|
|
||||||
|
// The canonical OccurredAtUtc is UTC by construction; columns store a
|
||||||
|
// Kind=Utc DateTime so downstream UTC/local conversions are safe
|
||||||
|
// (CLAUDE.md: "All timestamps are UTC throughout the system.").
|
||||||
|
var occurred = DateTime.SpecifyKind(evt.OccurredAtUtc.UtcDateTime, DateTimeKind.Utc);
|
||||||
|
DateTime? ingested = d.IngestedAtUtc.HasValue
|
||||||
|
? DateTime.SpecifyKind(d.IngestedAtUtc.Value.UtcDateTime, DateTimeKind.Utc)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return new AuditRowValues(
|
||||||
|
EventId: evt.EventId,
|
||||||
|
OccurredAtUtc: occurred,
|
||||||
|
IngestedAtUtc: ingested,
|
||||||
|
Channel: channel,
|
||||||
|
Kind: kind,
|
||||||
|
Status: status,
|
||||||
|
CorrelationId: evt.CorrelationId,
|
||||||
|
ExecutionId: d.ExecutionId,
|
||||||
|
ParentExecutionId: d.ParentExecutionId,
|
||||||
|
SourceSiteId: d.SourceSiteId,
|
||||||
|
SourceNode: evt.SourceNode,
|
||||||
|
SourceInstanceId: d.SourceInstanceId,
|
||||||
|
SourceScript: d.SourceScript,
|
||||||
|
// Canonical Actor is a required non-null string; an empty Actor maps
|
||||||
|
// back to a NULL column (legacy rows stored null for system/anon).
|
||||||
|
Actor: string.IsNullOrEmpty(evt.Actor) ? null : evt.Actor,
|
||||||
|
Target: evt.Target,
|
||||||
|
HttpStatus: d.HttpStatus,
|
||||||
|
DurationMs: d.DurationMs,
|
||||||
|
ErrorMessage: d.ErrorMessage,
|
||||||
|
ErrorDetail: d.ErrorDetail,
|
||||||
|
RequestSummary: d.RequestSummary,
|
||||||
|
ResponseSummary: d.ResponseSummary,
|
||||||
|
PayloadTruncated: d.PayloadTruncated,
|
||||||
|
Extra: d.Extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recomposes a canonical <see cref="AuditEvent"/> from the typed column values read
|
||||||
|
/// back from storage. The inverse of <see cref="Decompose"/>: Action/Category/Outcome
|
||||||
|
/// are rebuilt via the field builders / outcome projector, and every domain field is
|
||||||
|
/// re-serialized into <c>DetailsJson</c> via <see cref="AuditDetailsCodec"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static AuditEvent Recompose(in AuditRowValues v)
|
||||||
|
{
|
||||||
|
var details = new AuditDetails
|
||||||
|
{
|
||||||
|
Channel = v.Channel.ToString(),
|
||||||
|
Kind = v.Kind.ToString(),
|
||||||
|
Status = v.Status.ToString(),
|
||||||
|
ExecutionId = v.ExecutionId,
|
||||||
|
ParentExecutionId = v.ParentExecutionId,
|
||||||
|
SourceSiteId = v.SourceSiteId,
|
||||||
|
SourceInstanceId = v.SourceInstanceId,
|
||||||
|
SourceScript = v.SourceScript,
|
||||||
|
HttpStatus = v.HttpStatus,
|
||||||
|
DurationMs = v.DurationMs,
|
||||||
|
ErrorMessage = v.ErrorMessage,
|
||||||
|
ErrorDetail = v.ErrorDetail,
|
||||||
|
RequestSummary = v.RequestSummary,
|
||||||
|
ResponseSummary = v.ResponseSummary,
|
||||||
|
PayloadTruncated = v.PayloadTruncated,
|
||||||
|
Extra = v.Extra,
|
||||||
|
IngestedAtUtc = v.IngestedAtUtc.HasValue
|
||||||
|
? new DateTimeOffset(DateTime.SpecifyKind(v.IngestedAtUtc.Value, DateTimeKind.Utc))
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new AuditEvent
|
||||||
|
{
|
||||||
|
EventId = v.EventId,
|
||||||
|
OccurredAtUtc = new DateTimeOffset(
|
||||||
|
DateTime.SpecifyKind(v.OccurredAtUtc, DateTimeKind.Utc)),
|
||||||
|
Actor = v.Actor ?? string.Empty,
|
||||||
|
Action = AuditFieldBuilders.BuildAction(v.Channel, v.Kind),
|
||||||
|
Category = AuditFieldBuilders.BuildCategory(v.Channel),
|
||||||
|
Outcome = AuditOutcomeProjector.Project(v.Status, v.Kind),
|
||||||
|
Target = v.Target,
|
||||||
|
SourceNode = v.SourceNode,
|
||||||
|
CorrelationId = v.CorrelationId,
|
||||||
|
DetailsJson = AuditDetailsCodec.Serialize(details),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a copy of <paramref name="evt"/> with the central-side ingest timestamp
|
||||||
|
/// stamped into its <c>DetailsJson</c> (<see cref="AuditDetails.IngestedAtUtc"/>).
|
||||||
|
/// C3 transitional shim: <c>IngestedAtUtc</c> is a DetailsJson field on the canonical
|
||||||
|
/// record, so the central ingest paths stamp it here rather than on a top-level
|
||||||
|
/// property as the legacy bespoke record allowed.
|
||||||
|
/// </summary>
|
||||||
|
public static AuditEvent WithIngestedAtUtc(AuditEvent evt, DateTimeOffset ingestedAtUtc)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(evt);
|
||||||
|
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson) with
|
||||||
|
{
|
||||||
|
IngestedAtUtc = ingestedAtUtc.ToUniversalTime(),
|
||||||
|
};
|
||||||
|
return evt with { DetailsJson = AuditDetailsCodec.Serialize(d) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback) where TEnum : struct, Enum
|
||||||
|
=> !string.IsNullOrEmpty(value) && Enum.TryParse<TEnum>(value, ignoreCase: false, out var parsed)
|
||||||
|
? parsed
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convenience extension that decomposes a canonical <see cref="AuditEvent"/> into its
|
||||||
|
/// typed 24-field <see cref="AuditRowProjection.AuditRowValues"/> view. Lets callers
|
||||||
|
/// (and tests) read the ScadaBridge domain fields — Channel/Kind/Status + the DetailsJson
|
||||||
|
/// fields — as typed properties off a canonical row.
|
||||||
|
/// </summary>
|
||||||
|
public static class AuditEventRowExtensions
|
||||||
|
{
|
||||||
|
/// <summary>Decomposes this canonical record into its typed 24-field view.</summary>
|
||||||
|
public static AuditRowProjection.AuditRowValues AsRow(this AuditEvent evt)
|
||||||
|
=> AuditRowProjection.Decompose(evt);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single construction point for the canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/>
|
||||||
|
/// from ScadaBridge's domain vocabulary. Every emit site builds its row through
|
||||||
|
/// <see cref="Create"/> so the canonical-field mapping (Channel/Kind/Status →
|
||||||
|
/// Action/Category/Outcome, every other domain field → <see cref="AuditDetails"/>
|
||||||
|
/// inside <see cref="ZB.MOM.WW.Audit.AuditEvent.DetailsJson"/>) is applied
|
||||||
|
/// identically everywhere — no per-site drift.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>C3 of the ScadaBridge audit re-architecture (Task 2.5). The canonical
|
||||||
|
/// record is the type at every seam, emit site, DTO boundary, and redactor; the
|
||||||
|
/// ScadaBridge domain fields ride in <c>DetailsJson</c> via
|
||||||
|
/// <see cref="AuditDetailsCodec"/>.</para>
|
||||||
|
/// <para>Mapping (see Task 2.5 spec):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>Action</c> = <see cref="AuditFieldBuilders.BuildAction"/>(channel, kind).</item>
|
||||||
|
/// <item><c>Category</c> = <see cref="AuditFieldBuilders.BuildCategory"/>(channel) (= channel name).</item>
|
||||||
|
/// <item><c>Outcome</c> = <see cref="AuditOutcomeProjector.Project"/>(status, kind).</item>
|
||||||
|
/// <item><c>DetailsJson</c> carries Channel/Kind/Status (as strings) + every other
|
||||||
|
/// ScadaBridge domain field. <c>ForwardState</c> is NOT a DetailsJson field — it is
|
||||||
|
/// a site-storage-only concern handled by the site SQLite shim.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class ScadaBridgeAuditEventFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/> for one ScadaBridge
|
||||||
|
/// audit row. <paramref name="channel"/>/<paramref name="kind"/>/<paramref name="status"/>
|
||||||
|
/// drive the canonical Action/Category/Outcome and are also recorded (as strings) in
|
||||||
|
/// <c>DetailsJson</c>; all remaining ScadaBridge domain fields are carried in
|
||||||
|
/// <c>DetailsJson</c> too.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="channel">Trust-boundary channel the audited action crossed.</param>
|
||||||
|
/// <param name="kind">Specific event kind within the channel.</param>
|
||||||
|
/// <param name="status">Lifecycle status of this row.</param>
|
||||||
|
/// <param name="eventId">Idempotency key. Defaults to a fresh <see cref="Guid"/> when omitted.</param>
|
||||||
|
/// <param name="occurredAtUtc">When the action occurred (UTC). Defaults to <see cref="DateTime.UtcNow"/> when omitted.</param>
|
||||||
|
/// <param name="actor">Authenticated actor for inbound paths (API key name, user, "system", etc.).</param>
|
||||||
|
/// <param name="target">Target of the action (external system, db connection, list name, inbound method).</param>
|
||||||
|
/// <param name="sourceNode">Cluster node that emitted the event (top-level canonical field).</param>
|
||||||
|
/// <param name="correlationId">Per-operation lifecycle correlation id (top-level canonical field).</param>
|
||||||
|
/// <param name="executionId">Originating script-execution / inbound-request id (DetailsJson).</param>
|
||||||
|
/// <param name="parentExecutionId">Spawning execution's id (DetailsJson).</param>
|
||||||
|
/// <param name="sourceSiteId">Site id where the action originated (DetailsJson).</param>
|
||||||
|
/// <param name="sourceInstanceId">Instance id where the action originated (DetailsJson).</param>
|
||||||
|
/// <param name="sourceScript">Script that initiated the action (DetailsJson).</param>
|
||||||
|
/// <param name="httpStatus">HTTP status code where applicable (DetailsJson).</param>
|
||||||
|
/// <param name="durationMs">Duration of the audited action in ms (DetailsJson).</param>
|
||||||
|
/// <param name="errorMessage">Human-readable error summary on failure rows (DetailsJson).</param>
|
||||||
|
/// <param name="errorDetail">Verbose error detail (stack/exception) on failure rows (DetailsJson).</param>
|
||||||
|
/// <param name="requestSummary">Truncated/redacted request summary (DetailsJson).</param>
|
||||||
|
/// <param name="responseSummary">Truncated/redacted response summary (DetailsJson).</param>
|
||||||
|
/// <param name="payloadTruncated">True when summaries were truncated to the payload cap (DetailsJson).</param>
|
||||||
|
/// <param name="extra">Free-form JSON extension for channel-specific extras (DetailsJson).</param>
|
||||||
|
/// <param name="ingestedAtUtc">UTC ingest timestamp (central-set; DetailsJson).</param>
|
||||||
|
public static AuditEvent Create(
|
||||||
|
AuditChannel channel,
|
||||||
|
AuditKind kind,
|
||||||
|
AuditStatus status,
|
||||||
|
Guid? eventId = null,
|
||||||
|
DateTime? occurredAtUtc = null,
|
||||||
|
string? actor = null,
|
||||||
|
string? target = null,
|
||||||
|
string? sourceNode = null,
|
||||||
|
Guid? correlationId = null,
|
||||||
|
Guid? executionId = null,
|
||||||
|
Guid? parentExecutionId = null,
|
||||||
|
string? sourceSiteId = null,
|
||||||
|
string? sourceInstanceId = null,
|
||||||
|
string? sourceScript = null,
|
||||||
|
int? httpStatus = null,
|
||||||
|
int? durationMs = null,
|
||||||
|
string? errorMessage = null,
|
||||||
|
string? errorDetail = null,
|
||||||
|
string? requestSummary = null,
|
||||||
|
string? responseSummary = null,
|
||||||
|
bool payloadTruncated = false,
|
||||||
|
string? extra = null,
|
||||||
|
DateTimeOffset? ingestedAtUtc = null)
|
||||||
|
{
|
||||||
|
var details = new AuditDetails
|
||||||
|
{
|
||||||
|
Channel = channel.ToString(),
|
||||||
|
Kind = kind.ToString(),
|
||||||
|
Status = status.ToString(),
|
||||||
|
ExecutionId = executionId,
|
||||||
|
ParentExecutionId = parentExecutionId,
|
||||||
|
SourceSiteId = sourceSiteId,
|
||||||
|
SourceInstanceId = sourceInstanceId,
|
||||||
|
SourceScript = sourceScript,
|
||||||
|
HttpStatus = httpStatus,
|
||||||
|
DurationMs = durationMs,
|
||||||
|
ErrorMessage = errorMessage,
|
||||||
|
ErrorDetail = errorDetail,
|
||||||
|
RequestSummary = requestSummary,
|
||||||
|
ResponseSummary = responseSummary,
|
||||||
|
PayloadTruncated = payloadTruncated,
|
||||||
|
Extra = extra,
|
||||||
|
IngestedAtUtc = ingestedAtUtc,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new AuditEvent
|
||||||
|
{
|
||||||
|
EventId = eventId ?? Guid.NewGuid(),
|
||||||
|
// DateTimeOffset assumes UTC when the source DateTime is Unspecified/Utc;
|
||||||
|
// every ScadaBridge OccurredAt value is UTC by contract.
|
||||||
|
OccurredAtUtc = new DateTimeOffset(
|
||||||
|
DateTime.SpecifyKind(occurredAtUtc ?? DateTime.UtcNow, DateTimeKind.Utc)),
|
||||||
|
Actor = actor ?? string.Empty,
|
||||||
|
Action = AuditFieldBuilders.BuildAction(channel, kind),
|
||||||
|
Category = AuditFieldBuilders.BuildCategory(channel),
|
||||||
|
Outcome = AuditOutcomeProjector.Project(status, kind),
|
||||||
|
Target = target,
|
||||||
|
SourceNode = sourceNode,
|
||||||
|
CorrelationId = correlationId,
|
||||||
|
DetailsJson = AuditDetailsCodec.Serialize(details),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp;
|
using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp;
|
||||||
|
|
||||||
@@ -41,38 +42,44 @@ public static class AuditEventDtoMapper
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(evt);
|
ArgumentNullException.ThrowIfNull(evt);
|
||||||
|
|
||||||
|
// C3 (Task 2.5): the proto contract is the UNCHANGED 24-field wire. The
|
||||||
|
// canonical record carries the ScadaBridge domain fields inside
|
||||||
|
// DetailsJson — decompose them so the DTO's typed domain fields are
|
||||||
|
// populated exactly as before.
|
||||||
|
var r = AuditRowProjection.Decompose(evt);
|
||||||
|
|
||||||
var dto = new AuditEventDto
|
var dto = new AuditEventDto
|
||||||
{
|
{
|
||||||
EventId = evt.EventId.ToString(),
|
EventId = r.EventId.ToString(),
|
||||||
OccurredAtUtc = Timestamp.FromDateTime(EnsureUtc(evt.OccurredAtUtc)),
|
OccurredAtUtc = Timestamp.FromDateTime(EnsureUtc(r.OccurredAtUtc)),
|
||||||
Channel = evt.Channel.ToString(),
|
Channel = r.Channel.ToString(),
|
||||||
Kind = evt.Kind.ToString(),
|
Kind = r.Kind.ToString(),
|
||||||
CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty,
|
CorrelationId = r.CorrelationId?.ToString() ?? string.Empty,
|
||||||
ExecutionId = evt.ExecutionId?.ToString() ?? string.Empty,
|
ExecutionId = r.ExecutionId?.ToString() ?? string.Empty,
|
||||||
ParentExecutionId = evt.ParentExecutionId?.ToString() ?? string.Empty,
|
ParentExecutionId = r.ParentExecutionId?.ToString() ?? string.Empty,
|
||||||
SourceSiteId = evt.SourceSiteId ?? string.Empty,
|
SourceSiteId = r.SourceSiteId ?? string.Empty,
|
||||||
SourceNode = evt.SourceNode ?? string.Empty,
|
SourceNode = r.SourceNode ?? string.Empty,
|
||||||
SourceInstanceId = evt.SourceInstanceId ?? string.Empty,
|
SourceInstanceId = r.SourceInstanceId ?? string.Empty,
|
||||||
SourceScript = evt.SourceScript ?? string.Empty,
|
SourceScript = r.SourceScript ?? string.Empty,
|
||||||
Actor = evt.Actor ?? string.Empty,
|
Actor = r.Actor ?? string.Empty,
|
||||||
Target = evt.Target ?? string.Empty,
|
Target = r.Target ?? string.Empty,
|
||||||
Status = evt.Status.ToString(),
|
Status = r.Status.ToString(),
|
||||||
ErrorMessage = evt.ErrorMessage ?? string.Empty,
|
ErrorMessage = r.ErrorMessage ?? string.Empty,
|
||||||
ErrorDetail = evt.ErrorDetail ?? string.Empty,
|
ErrorDetail = r.ErrorDetail ?? string.Empty,
|
||||||
RequestSummary = evt.RequestSummary ?? string.Empty,
|
RequestSummary = r.RequestSummary ?? string.Empty,
|
||||||
ResponseSummary = evt.ResponseSummary ?? string.Empty,
|
ResponseSummary = r.ResponseSummary ?? string.Empty,
|
||||||
PayloadTruncated = evt.PayloadTruncated,
|
PayloadTruncated = r.PayloadTruncated,
|
||||||
Extra = evt.Extra ?? string.Empty
|
Extra = r.Extra ?? string.Empty
|
||||||
};
|
};
|
||||||
|
|
||||||
if (evt.HttpStatus.HasValue)
|
if (r.HttpStatus.HasValue)
|
||||||
{
|
{
|
||||||
dto.HttpStatus = evt.HttpStatus.Value;
|
dto.HttpStatus = r.HttpStatus.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evt.DurationMs.HasValue)
|
if (r.DurationMs.HasValue)
|
||||||
{
|
{
|
||||||
dto.DurationMs = evt.DurationMs.Value;
|
dto.DurationMs = r.DurationMs.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return dto;
|
return dto;
|
||||||
@@ -89,33 +96,35 @@ public static class AuditEventDtoMapper
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(dto);
|
ArgumentNullException.ThrowIfNull(dto);
|
||||||
|
|
||||||
return new AuditEvent
|
// C3 (Task 2.5): recompose the canonical record from the 24-field wire
|
||||||
{
|
// DTO. The domain fields are re-serialized into DetailsJson via the
|
||||||
EventId = Guid.Parse(dto.EventId),
|
// projection helper; IngestedAtUtc is left null (central sets it at
|
||||||
OccurredAtUtc = DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc),
|
// ingest) and ForwardState is dropped (site-storage-only, never on the
|
||||||
IngestedAtUtc = null,
|
// wire).
|
||||||
Channel = Enum.Parse<AuditChannel>(dto.Channel),
|
return AuditRowProjection.Recompose(new AuditRowProjection.AuditRowValues(
|
||||||
Kind = Enum.Parse<AuditKind>(dto.Kind),
|
EventId: Guid.Parse(dto.EventId),
|
||||||
CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
|
OccurredAtUtc: DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc),
|
||||||
ExecutionId = NullIfEmpty(dto.ExecutionId) is { } eid ? Guid.Parse(eid) : null,
|
IngestedAtUtc: null,
|
||||||
ParentExecutionId = NullIfEmpty(dto.ParentExecutionId) is { } pid ? Guid.Parse(pid) : null,
|
Channel: Enum.Parse<AuditChannel>(dto.Channel),
|
||||||
SourceSiteId = NullIfEmpty(dto.SourceSiteId),
|
Kind: Enum.Parse<AuditKind>(dto.Kind),
|
||||||
SourceNode = NullIfEmpty(dto.SourceNode),
|
Status: Enum.Parse<AuditStatus>(dto.Status),
|
||||||
SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
|
CorrelationId: NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
|
||||||
SourceScript = NullIfEmpty(dto.SourceScript),
|
ExecutionId: NullIfEmpty(dto.ExecutionId) is { } eid ? Guid.Parse(eid) : null,
|
||||||
Actor = NullIfEmpty(dto.Actor),
|
ParentExecutionId: NullIfEmpty(dto.ParentExecutionId) is { } pid ? Guid.Parse(pid) : null,
|
||||||
Target = NullIfEmpty(dto.Target),
|
SourceSiteId: NullIfEmpty(dto.SourceSiteId),
|
||||||
Status = Enum.Parse<AuditStatus>(dto.Status),
|
SourceNode: NullIfEmpty(dto.SourceNode),
|
||||||
HttpStatus = dto.HttpStatus,
|
SourceInstanceId: NullIfEmpty(dto.SourceInstanceId),
|
||||||
DurationMs = dto.DurationMs,
|
SourceScript: NullIfEmpty(dto.SourceScript),
|
||||||
ErrorMessage = NullIfEmpty(dto.ErrorMessage),
|
Actor: NullIfEmpty(dto.Actor),
|
||||||
ErrorDetail = NullIfEmpty(dto.ErrorDetail),
|
Target: NullIfEmpty(dto.Target),
|
||||||
RequestSummary = NullIfEmpty(dto.RequestSummary),
|
HttpStatus: dto.HttpStatus,
|
||||||
ResponseSummary = NullIfEmpty(dto.ResponseSummary),
|
DurationMs: dto.DurationMs,
|
||||||
PayloadTruncated = dto.PayloadTruncated,
|
ErrorMessage: NullIfEmpty(dto.ErrorMessage),
|
||||||
Extra = NullIfEmpty(dto.Extra),
|
ErrorDetail: NullIfEmpty(dto.ErrorDetail),
|
||||||
ForwardState = null
|
RequestSummary: NullIfEmpty(dto.RequestSummary),
|
||||||
};
|
ResponseSummary: NullIfEmpty(dto.ResponseSummary),
|
||||||
|
PayloadTruncated: dto.PayloadTruncated,
|
||||||
|
Extra: NullIfEmpty(dto.Extra)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? NullIfEmpty(string? value) =>
|
private static string? NullIfEmpty(string? value) =>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using Akka.Actor;
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
|
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
|
||||||
|
|||||||
+9
-7
@@ -1,16 +1,18 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps the <see cref="AuditEvent"/> record to the central <c>AuditLog</c> table
|
/// Maps the <see cref="AuditLogRow"/> persistence shape to the central <c>AuditLog</c>
|
||||||
/// described in alog.md §4. Column lengths/types and the five named indexes are
|
/// table described in alog.md §4. Column lengths/types and the named indexes are
|
||||||
/// fixed by that specification — keep this in sync with the doc.
|
/// fixed by that specification — keep this in sync with the doc. C3 (Task 2.5) kept
|
||||||
|
/// the table unchanged; the canonical record is mapped onto this row at the repository
|
||||||
|
/// boundary via <c>AuditRowProjection</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEvent>
|
public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditLogRow>
|
||||||
{
|
{
|
||||||
// SQL Server's datetime2 provider strips the DateTimeKind flag on the wire
|
// SQL Server's datetime2 provider strips the DateTimeKind flag on the wire
|
||||||
// (a column hydrated from the database always surfaces as
|
// (a column hydrated from the database always surfaces as
|
||||||
@@ -33,9 +35,9 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEve
|
|||||||
: null,
|
: null,
|
||||||
v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : null);
|
v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : null);
|
||||||
|
|
||||||
/// <summary>Applies the EF Core type configuration for <see cref="AuditEvent"/> to the model builder.</summary>
|
/// <summary>Applies the EF Core type configuration for <see cref="AuditLogRow"/> to the model builder.</summary>
|
||||||
/// <param name="builder">The entity type builder to configure.</param>
|
/// <param name="builder">The entity type builder to configure.</param>
|
||||||
public void Configure(EntityTypeBuilder<AuditEvent> builder)
|
public void Configure(EntityTypeBuilder<AuditLogRow> builder)
|
||||||
{
|
{
|
||||||
builder.ToTable("AuditLog");
|
builder.ToTable("AuditLog");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transitional EF Core persistence shape for the central <c>dbo.AuditLog</c> table
|
||||||
|
/// (Audit Log #23). This is the 24-column row formerly modelled by
|
||||||
|
/// <c>ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditEvent</c>; in C3 (Task 2.5)
|
||||||
|
/// the canonical <c>ZB.MOM.WW.Audit.AuditEvent</c> became the type at every seam,
|
||||||
|
/// emit site, DTO boundary, and redactor, and this row type was relocated here as a
|
||||||
|
/// storage-only entity so the existing table keeps working unchanged.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The repository maps canonical ⇄ this row at the persistence boundary via
|
||||||
|
/// <c>ZB.MOM.WW.ScadaBridge.Commons.Types.Audit.AuditRowProjection</c>. C5 replaces
|
||||||
|
/// this shim + table with the real DetailsJson-backed schema.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// All <c>*Utc</c>-suffixed <see cref="DateTime"/> properties are invariantly UTC
|
||||||
|
/// (CLAUDE.md: "All timestamps are UTC throughout the system."). The init-setters
|
||||||
|
/// force <see cref="DateTimeKind.Utc"/> on assignment so a value re-hydrated from a
|
||||||
|
/// SQL Server <c>datetime2</c> column (which strips the <c>Kind</c> flag on the wire)
|
||||||
|
/// cannot leak downstream as <see cref="DateTimeKind.Unspecified"/> or be silently
|
||||||
|
/// re-interpreted as local time.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record AuditLogRow
|
||||||
|
{
|
||||||
|
/// <summary>Idempotency key; uniquely identifies one audit lifecycle event.</summary>
|
||||||
|
public Guid EventId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>UTC timestamp when the audited action occurred at its source.</summary>
|
||||||
|
public DateTime OccurredAtUtc
|
||||||
|
{
|
||||||
|
get => _occurredAtUtc;
|
||||||
|
init => _occurredAtUtc = DateTime.SpecifyKind(value, DateTimeKind.Utc);
|
||||||
|
}
|
||||||
|
private readonly DateTime _occurredAtUtc;
|
||||||
|
|
||||||
|
/// <summary>UTC timestamp when the row was ingested at central; null on the site hot-path.</summary>
|
||||||
|
public DateTime? IngestedAtUtc
|
||||||
|
{
|
||||||
|
get => _ingestedAtUtc;
|
||||||
|
init => _ingestedAtUtc = value.HasValue
|
||||||
|
? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
private readonly DateTime? _ingestedAtUtc;
|
||||||
|
|
||||||
|
/// <summary>Trust-boundary channel the audited action crossed.</summary>
|
||||||
|
public AuditChannel Channel { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Specific event kind within the channel.</summary>
|
||||||
|
public AuditKind Kind { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
|
||||||
|
public Guid? CorrelationId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Id of the originating script execution / inbound request.</summary>
|
||||||
|
public Guid? ExecutionId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>ExecutionId of the execution that spawned this run; null for top-level runs.</summary>
|
||||||
|
public Guid? ParentExecutionId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Site id where the action originated; null for central-direct events.</summary>
|
||||||
|
public string? SourceSiteId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The cluster node on which the event was emitted.</summary>
|
||||||
|
public string? SourceNode { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Instance id where the action originated, when applicable.</summary>
|
||||||
|
public string? SourceInstanceId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Script that initiated the action, when applicable.</summary>
|
||||||
|
public string? SourceScript { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Authenticated actor for inbound paths (API key name, user, etc.).</summary>
|
||||||
|
public string? Actor { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Target of the action: external system name, db connection name, list name, or inbound method.</summary>
|
||||||
|
public string? Target { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Lifecycle status of this row.</summary>
|
||||||
|
public AuditStatus Status { get; init; }
|
||||||
|
|
||||||
|
/// <summary>HTTP status code where applicable.</summary>
|
||||||
|
public int? HttpStatus { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Duration of the audited action in milliseconds, when measurable.</summary>
|
||||||
|
public int? DurationMs { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Human-readable error summary on failure rows.</summary>
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Verbose error detail (stack/exception) on failure rows.</summary>
|
||||||
|
public string? ErrorDetail { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Truncated/redacted request summary; capped per AuditLogOptions.</summary>
|
||||||
|
public string? RequestSummary { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Truncated/redacted response summary; capped per AuditLogOptions.</summary>
|
||||||
|
public string? ResponseSummary { get; init; }
|
||||||
|
|
||||||
|
/// <summary>True when Request/Response summaries were truncated to the payload cap.</summary>
|
||||||
|
public bool PayloadTruncated { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Free-form JSON extension column for channel-specific extras.</summary>
|
||||||
|
public string? Extra { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Site-local forwarding state; null on central rows.</summary>
|
||||||
|
public AuditForwardState? ForwardState { get; init; }
|
||||||
|
}
|
||||||
+1
-1
@@ -41,7 +41,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
|||||||
b.ToTable("DataProtectionKeys");
|
b.ToTable("DataProtectionKeys");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditEvent", b =>
|
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("EventId")
|
b.Property<Guid>("EventId")
|
||||||
.HasColumnType("uniqueidentifier");
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|||||||
+58
-14
@@ -2,10 +2,11 @@ using Microsoft.Data.SqlClient;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||||
|
|
||||||
@@ -45,30 +46,36 @@ public class AuditLogRepository : IAuditLogRepository
|
|||||||
throw new ArgumentNullException(nameof(evt));
|
throw new ArgumentNullException(nameof(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// C3 transitional shim: the canonical record carries the ScadaBridge domain
|
||||||
|
// fields inside DetailsJson — decompose it into the typed 24-column values the
|
||||||
|
// existing dbo.AuditLog table expects. Central rows leave ForwardState null
|
||||||
|
// (it is a site-storage-only concern, never on a central row).
|
||||||
|
var r = AuditRowProjection.Decompose(evt);
|
||||||
|
|
||||||
// Enum columns are stored as varchar(32) (HasConversion<string>()), so do
|
// Enum columns are stored as varchar(32) (HasConversion<string>()), so do
|
||||||
// the conversion in C# rather than relying on parameter type inference —
|
// the conversion in C# rather than relying on parameter type inference —
|
||||||
// SqlClient would otherwise bind enums as int by default.
|
// SqlClient would otherwise bind enums as int by default.
|
||||||
var channel = evt.Channel.ToString();
|
var channel = r.Channel.ToString();
|
||||||
var kind = evt.Kind.ToString();
|
var kind = r.Kind.ToString();
|
||||||
var status = evt.Status.ToString();
|
var status = r.Status.ToString();
|
||||||
var forwardState = evt.ForwardState?.ToString();
|
string? forwardState = null;
|
||||||
|
|
||||||
// FormattableString interpolation parameterises every value (no concatenation),
|
// FormattableString interpolation parameterises every value (no concatenation),
|
||||||
// so this is safe against injection even for the string columns.
|
// so this is safe against injection even for the string columns.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _context.Database.ExecuteSqlInterpolatedAsync(
|
await _context.Database.ExecuteSqlInterpolatedAsync(
|
||||||
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
|
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {r.EventId})
|
||||||
INSERT INTO dbo.AuditLog
|
INSERT INTO dbo.AuditLog
|
||||||
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId, ParentExecutionId,
|
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId, ParentExecutionId,
|
||||||
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, Status,
|
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, Status,
|
||||||
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
|
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
|
||||||
ResponseSummary, PayloadTruncated, Extra, ForwardState)
|
ResponseSummary, PayloadTruncated, Extra, ForwardState)
|
||||||
VALUES
|
VALUES
|
||||||
({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, {evt.ExecutionId}, {evt.ParentExecutionId},
|
({r.EventId}, {r.OccurredAtUtc}, {r.IngestedAtUtc}, {channel}, {kind}, {r.CorrelationId}, {r.ExecutionId}, {r.ParentExecutionId},
|
||||||
{evt.SourceSiteId}, {evt.SourceNode}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
|
{r.SourceSiteId}, {r.SourceNode}, {r.SourceInstanceId}, {r.SourceScript}, {r.Actor}, {r.Target}, {status},
|
||||||
{evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
|
{r.HttpStatus}, {r.DurationMs}, {r.ErrorMessage}, {r.ErrorDetail}, {r.RequestSummary},
|
||||||
{evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
|
{r.ResponseSummary}, {r.PayloadTruncated}, {r.Extra}, {forwardState});",
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
catch (SqlException ex) when (
|
catch (SqlException ex) when (
|
||||||
@@ -85,7 +92,7 @@ VALUES
|
|||||||
ex,
|
ex,
|
||||||
"InsertIfNotExistsAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for EventId {EventId}; treating as no-op.",
|
"InsertIfNotExistsAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for EventId {EventId}; treating as no-op.",
|
||||||
ex.Number,
|
ex.Number,
|
||||||
evt.EventId);
|
r.EventId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +110,10 @@ VALUES
|
|||||||
throw new ArgumentNullException(nameof(paging));
|
throw new ArgumentNullException(nameof(paging));
|
||||||
}
|
}
|
||||||
|
|
||||||
var query = _context.Set<AuditEvent>().AsNoTracking();
|
// C3 transitional shim: the typed-column filter predicates query the
|
||||||
|
// AuditLogRow persistence shape as before (C6 retargets how the filter is
|
||||||
|
// applied); the materialized rows are recomposed into canonical records.
|
||||||
|
var query = _context.Set<AuditLogRow>().AsNoTracking();
|
||||||
|
|
||||||
// Multi-value dimensions: a null OR empty list means "no constraint"
|
// Multi-value dimensions: a null OR empty list means "no constraint"
|
||||||
// (the { Count: > 0 } guard prevents an empty list collapsing to a
|
// (the { Count: > 0 } guard prevents an empty list collapsing to a
|
||||||
@@ -181,13 +191,47 @@ VALUES
|
|||||||
|| (e.OccurredAtUtc == afterOccurred && e.EventId.CompareTo(afterEventId) < 0));
|
|| (e.OccurredAtUtc == afterOccurred && e.EventId.CompareTo(afterEventId) < 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
return await query
|
var rows = await query
|
||||||
.OrderByDescending(e => e.OccurredAtUtc)
|
.OrderByDescending(e => e.OccurredAtUtc)
|
||||||
.ThenByDescending(e => e.EventId)
|
.ThenByDescending(e => e.EventId)
|
||||||
.Take(paging.PageSize)
|
.Take(paging.PageSize)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return rows.Select(RowToCanonical).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// C3 transitional shim: recompose a canonical <see cref="AuditEvent"/> from a
|
||||||
|
/// materialized <see cref="AuditLogRow"/> read back from <c>dbo.AuditLog</c>.
|
||||||
|
/// <c>ForwardState</c> is dropped (central rows never carry it; it is not a
|
||||||
|
/// canonical / DetailsJson field).
|
||||||
|
/// </summary>
|
||||||
|
private static AuditEvent RowToCanonical(AuditLogRow row)
|
||||||
|
=> AuditRowProjection.Recompose(new AuditRowProjection.AuditRowValues(
|
||||||
|
EventId: row.EventId,
|
||||||
|
OccurredAtUtc: row.OccurredAtUtc,
|
||||||
|
IngestedAtUtc: row.IngestedAtUtc,
|
||||||
|
Channel: row.Channel,
|
||||||
|
Kind: row.Kind,
|
||||||
|
Status: row.Status,
|
||||||
|
CorrelationId: row.CorrelationId,
|
||||||
|
ExecutionId: row.ExecutionId,
|
||||||
|
ParentExecutionId: row.ParentExecutionId,
|
||||||
|
SourceSiteId: row.SourceSiteId,
|
||||||
|
SourceNode: row.SourceNode,
|
||||||
|
SourceInstanceId: row.SourceInstanceId,
|
||||||
|
SourceScript: row.SourceScript,
|
||||||
|
Actor: row.Actor,
|
||||||
|
Target: row.Target,
|
||||||
|
HttpStatus: row.HttpStatus,
|
||||||
|
DurationMs: row.DurationMs,
|
||||||
|
ErrorMessage: row.ErrorMessage,
|
||||||
|
ErrorDetail: row.ErrorDetail,
|
||||||
|
RequestSummary: row.RequestSummary,
|
||||||
|
ResponseSummary: row.ResponseSummary,
|
||||||
|
PayloadTruncated: row.PayloadTruncated,
|
||||||
|
Extra: row.Extra));
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
|
public async Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -674,7 +718,7 @@ VALUES
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
|
public async Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
return await _context.Set<AuditEvent>()
|
return await _context.Set<AuditLogRow>()
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(e => e.SourceNode != null)
|
.Where(e => e.SourceNode != null)
|
||||||
.Select(e => e.SourceNode!)
|
.Select(e => e.SourceNode!)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||||
|
|
||||||
@@ -124,8 +125,8 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
|
|||||||
// Audit
|
// Audit
|
||||||
/// <summary>Gets the set of audit log entries.</summary>
|
/// <summary>Gets the set of audit log entries.</summary>
|
||||||
public DbSet<AuditLogEntry> AuditLogEntries => Set<AuditLogEntry>();
|
public DbSet<AuditLogEntry> AuditLogEntries => Set<AuditLogEntry>();
|
||||||
/// <summary>Gets the set of audit logs.</summary>
|
/// <summary>Gets the set of audit log rows (central <c>dbo.AuditLog</c> persistence shape; mapped to/from the canonical record at the repository boundary).</summary>
|
||||||
public DbSet<AuditEvent> AuditLogs => Set<AuditEvent>();
|
public DbSet<AuditLogRow> AuditLogs => Set<AuditLogRow>();
|
||||||
/// <summary>Gets the set of site calls.</summary>
|
/// <summary>Gets the set of site calls.</summary>
|
||||||
public DbSet<SiteCall> SiteCalls => Set<SiteCall>();
|
public DbSet<SiteCall> SiteCalls => Set<SiteCall>();
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ using System.Text.Json;
|
|||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
|
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
|
||||||
@@ -234,37 +235,34 @@ public sealed class AuditWriteMiddleware
|
|||||||
userAgent = ctx.Request.Headers.UserAgent.ToString(),
|
userAgent = ctx.Request.Headers.UserAgent.ToString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
var evt = new AuditEvent
|
var evt = ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
channel: AuditChannel.ApiInbound,
|
||||||
EventId = Guid.NewGuid(),
|
kind: kind,
|
||||||
OccurredAtUtc = DateTime.UtcNow,
|
status: status,
|
||||||
Channel = AuditChannel.ApiInbound,
|
occurredAtUtc: DateTime.UtcNow,
|
||||||
Kind = kind,
|
actor: actor,
|
||||||
|
target: methodName,
|
||||||
// Audit Log #23: the per-request execution id minted ONCE at the
|
// Audit Log #23: the per-request execution id minted ONCE at the
|
||||||
// start of the request (InvokeAsync) and stashed on
|
// start of the request (InvokeAsync) and stashed on
|
||||||
// HttpContext.Items. The same id is threaded onto a routed
|
// HttpContext.Items. The same id is threaded onto a routed
|
||||||
// RouteToCallRequest.ParentExecutionId by the endpoint handler,
|
// RouteToCallRequest.ParentExecutionId by the endpoint handler,
|
||||||
// so an inbound request and the site script it routes to share
|
// so an inbound request and the site script it routes to share
|
||||||
// one correlation point. This inbound row stays top-level — its
|
// one correlation point. This inbound row stays top-level — its
|
||||||
// own ParentExecutionId is never set (see below).
|
// own ParentExecutionId is never set.
|
||||||
ExecutionId = ResolveInboundExecutionId(ctx),
|
executionId: ResolveInboundExecutionId(ctx),
|
||||||
// CorrelationId is purely the per-operation-lifecycle id; an
|
// CorrelationId is purely the per-operation-lifecycle id; an
|
||||||
// inbound request is a one-shot from the audit row's
|
// inbound request is a one-shot from the audit row's
|
||||||
// perspective with no multi-row operation to correlate.
|
// perspective with no multi-row operation to correlate.
|
||||||
CorrelationId = null,
|
correlationId: null,
|
||||||
Actor = actor,
|
httpStatus: statusCode,
|
||||||
Target = methodName,
|
durationMs: (int)Math.Min(durationMs, int.MaxValue),
|
||||||
Status = status,
|
errorMessage: thrown?.Message,
|
||||||
HttpStatus = statusCode,
|
requestSummary: requestBody,
|
||||||
DurationMs = (int)Math.Min(durationMs, int.MaxValue),
|
responseSummary: responseBody,
|
||||||
ErrorMessage = thrown?.Message,
|
payloadTruncated: payloadTruncated,
|
||||||
RequestSummary = requestBody,
|
extra: extra);
|
||||||
ResponseSummary = responseBody,
|
// Central direct-write — no site-local forwarding state (not a
|
||||||
PayloadTruncated = payloadTruncated,
|
// canonical field).
|
||||||
Extra = extra,
|
|
||||||
// Central direct-write — no site-local forwarding state.
|
|
||||||
ForwardState = null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// InboundAPI-018: fire-and-forget the writer so the user-facing
|
// InboundAPI-018: fire-and-forget the writer so the user-facing
|
||||||
// response stays non-blocking (alog.md §13 — audit emission must
|
// response stays non-blocking (alog.md §13 — audit emission must
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Builder;
|
|||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
@@ -127,22 +127,26 @@ public static class AuditEndpoints
|
|||||||
var paging = ParsePaging(context.Request.Query);
|
var paging = ParsePaging(context.Request.Query);
|
||||||
|
|
||||||
var repo = context.RequestServices.GetRequiredService<IAuditLogRepository>();
|
var repo = context.RequestServices.GetRequiredService<IAuditLogRepository>();
|
||||||
var events = await repo.QueryAsync(filter, paging, context.RequestAborted);
|
var canonical = await repo.QueryAsync(filter, paging, context.RequestAborted);
|
||||||
|
|
||||||
// The cursor for the next page is the last row of this page — but only
|
// The cursor for the next page is the last row of this page — but only
|
||||||
// when the page came back FULL. A short page means there is no next
|
// when the page came back FULL. A short page means there is no next
|
||||||
// page, so nextCursor is null and the CLI stops paging.
|
// page, so nextCursor is null and the CLI stops paging.
|
||||||
object? nextCursor = null;
|
object? nextCursor = null;
|
||||||
if (events.Count == paging.PageSize && events.Count > 0)
|
if (canonical.Count == paging.PageSize && canonical.Count > 0)
|
||||||
{
|
{
|
||||||
var last = events[^1];
|
var last = canonical[^1];
|
||||||
nextCursor = new
|
nextCursor = new
|
||||||
{
|
{
|
||||||
afterOccurredAtUtc = last.OccurredAtUtc,
|
// C3: canonical OccurredAtUtc is a DateTimeOffset; the cursor key is UTC.
|
||||||
|
afterOccurredAtUtc = last.OccurredAtUtc.UtcDateTime,
|
||||||
afterEventId = last.EventId,
|
afterEventId = last.EventId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// C3 (Task 2.5): decompose canonical rows into the flat AuditExportRow so the
|
||||||
|
// CLI's JSON shape (24-field) is unchanged.
|
||||||
|
var events = canonical.Select(AuditExportRow.From).ToList();
|
||||||
var payload = new { events, nextCursor };
|
var payload = new { events, nextCursor };
|
||||||
// EnvelopeJsonOptions keeps an explicit null nextCursor on the wire so
|
// EnvelopeJsonOptions keeps an explicit null nextCursor on the wire so
|
||||||
// the CLI can always read the key. AuditEvent rows render with their
|
// the CLI can always read the key. AuditEvent rows render with their
|
||||||
@@ -248,7 +252,7 @@ public static class AuditEndpoints
|
|||||||
{
|
{
|
||||||
foreach (var evt in page)
|
foreach (var evt in page)
|
||||||
{
|
{
|
||||||
await writer.WriteLineAsync(FormatCsvRow(evt));
|
await writer.WriteLineAsync(FormatCsvRow(AuditExportRow.From(evt)));
|
||||||
}
|
}
|
||||||
await writer.FlushAsync(ct);
|
await writer.FlushAsync(ct);
|
||||||
await output.FlushAsync(ct);
|
await output.FlushAsync(ct);
|
||||||
@@ -275,7 +279,7 @@ public static class AuditEndpoints
|
|||||||
{
|
{
|
||||||
foreach (var evt in page)
|
foreach (var evt in page)
|
||||||
{
|
{
|
||||||
await writer.WriteLineAsync(JsonSerializer.Serialize(evt, JsonOptions));
|
await writer.WriteLineAsync(JsonSerializer.Serialize(AuditExportRow.From(evt), JsonOptions));
|
||||||
}
|
}
|
||||||
await writer.FlushAsync(ct);
|
await writer.FlushAsync(ct);
|
||||||
await output.FlushAsync(ct);
|
await output.FlushAsync(ct);
|
||||||
@@ -309,7 +313,8 @@ public static class AuditEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
var last = page[^1];
|
var last = page[^1];
|
||||||
cursor = new AuditLogPaging(ExportPageSize, last.OccurredAtUtc, last.EventId);
|
// C3: canonical OccurredAtUtc is a DateTimeOffset; the keyset cursor column is UTC.
|
||||||
|
cursor = new AuditLogPaging(ExportPageSize, last.OccurredAtUtc.UtcDateTime, last.EventId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,7 +576,7 @@ public static class AuditEndpoints
|
|||||||
/// Formats a single <see cref="AuditEvent"/> as an RFC 4180 CSV row matching <see cref="CsvHeader"/>.
|
/// Formats a single <see cref="AuditEvent"/> as an RFC 4180 CSV row matching <see cref="CsvHeader"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="evt">The audit event to format.</param>
|
/// <param name="evt">The audit event to format.</param>
|
||||||
public static string FormatCsvRow(AuditEvent evt)
|
public static string FormatCsvRow(AuditExportRow evt)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder(256);
|
var sb = new StringBuilder(256);
|
||||||
AppendField(sb, evt.EventId.ToString(), first: true);
|
AppendField(sb, evt.EventId.ToString(), first: true);
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flat, wire-shape view of a canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/> for the
|
||||||
|
/// management CLI's <c>/api/audit/query</c> + <c>/api/audit/export</c> endpoints. C3
|
||||||
|
/// (Task 2.5) made the canonical record the repository seam type; this DTO preserves the
|
||||||
|
/// existing 24-field JSON/CSV shape the CLI consumes by decomposing the canonical row
|
||||||
|
/// (via <see cref="AuditRowProjection"/>) at the endpoint boundary.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AuditExportRow
|
||||||
|
{
|
||||||
|
/// <summary>Idempotency key.</summary>
|
||||||
|
public Guid EventId { get; init; }
|
||||||
|
/// <summary>UTC timestamp when the audited action occurred.</summary>
|
||||||
|
public DateTime OccurredAtUtc { get; init; }
|
||||||
|
/// <summary>UTC ingest timestamp; null until ingest.</summary>
|
||||||
|
public DateTime? IngestedAtUtc { get; init; }
|
||||||
|
/// <summary>Trust-boundary channel.</summary>
|
||||||
|
public AuditChannel Channel { get; init; }
|
||||||
|
/// <summary>Specific event kind.</summary>
|
||||||
|
public AuditKind Kind { get; init; }
|
||||||
|
/// <summary>Per-operation correlation id.</summary>
|
||||||
|
public Guid? CorrelationId { get; init; }
|
||||||
|
/// <summary>Originating execution id.</summary>
|
||||||
|
public Guid? ExecutionId { get; init; }
|
||||||
|
/// <summary>Spawning execution id; null for top-level runs.</summary>
|
||||||
|
public Guid? ParentExecutionId { get; init; }
|
||||||
|
/// <summary>Site id where the action originated.</summary>
|
||||||
|
public string? SourceSiteId { get; init; }
|
||||||
|
/// <summary>Cluster node that emitted the event.</summary>
|
||||||
|
public string? SourceNode { get; init; }
|
||||||
|
/// <summary>Instance id where the action originated.</summary>
|
||||||
|
public string? SourceInstanceId { get; init; }
|
||||||
|
/// <summary>Script that initiated the action.</summary>
|
||||||
|
public string? SourceScript { get; init; }
|
||||||
|
/// <summary>Authenticated actor.</summary>
|
||||||
|
public string? Actor { get; init; }
|
||||||
|
/// <summary>Target of the action.</summary>
|
||||||
|
public string? Target { get; init; }
|
||||||
|
/// <summary>Lifecycle status.</summary>
|
||||||
|
public AuditStatus Status { get; init; }
|
||||||
|
/// <summary>HTTP status code where applicable.</summary>
|
||||||
|
public int? HttpStatus { get; init; }
|
||||||
|
/// <summary>Duration of the action in ms.</summary>
|
||||||
|
public int? DurationMs { get; init; }
|
||||||
|
/// <summary>Human-readable error summary.</summary>
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
/// <summary>Verbose error detail.</summary>
|
||||||
|
public string? ErrorDetail { get; init; }
|
||||||
|
/// <summary>Truncated/redacted request summary.</summary>
|
||||||
|
public string? RequestSummary { get; init; }
|
||||||
|
/// <summary>Truncated/redacted response summary.</summary>
|
||||||
|
public string? ResponseSummary { get; init; }
|
||||||
|
/// <summary>True when summaries were truncated.</summary>
|
||||||
|
public bool PayloadTruncated { get; init; }
|
||||||
|
/// <summary>Free-form JSON extension.</summary>
|
||||||
|
public string? Extra { get; init; }
|
||||||
|
/// <summary>Site-local forwarding state; always null on the central read path.</summary>
|
||||||
|
public AuditForwardState? ForwardState { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Decomposes a canonical <see cref="AuditEvent"/> into this flat export shape.</summary>
|
||||||
|
public static AuditExportRow From(AuditEvent evt)
|
||||||
|
{
|
||||||
|
var r = AuditRowProjection.Decompose(evt);
|
||||||
|
return new AuditExportRow
|
||||||
|
{
|
||||||
|
EventId = r.EventId,
|
||||||
|
OccurredAtUtc = r.OccurredAtUtc,
|
||||||
|
IngestedAtUtc = r.IngestedAtUtc,
|
||||||
|
Channel = r.Channel,
|
||||||
|
Kind = r.Kind,
|
||||||
|
CorrelationId = r.CorrelationId,
|
||||||
|
ExecutionId = r.ExecutionId,
|
||||||
|
ParentExecutionId = r.ParentExecutionId,
|
||||||
|
SourceSiteId = r.SourceSiteId,
|
||||||
|
SourceNode = r.SourceNode,
|
||||||
|
SourceInstanceId = r.SourceInstanceId,
|
||||||
|
SourceScript = r.SourceScript,
|
||||||
|
Actor = r.Actor,
|
||||||
|
Target = r.Target,
|
||||||
|
Status = r.Status,
|
||||||
|
HttpStatus = r.HttpStatus,
|
||||||
|
DurationMs = r.DurationMs,
|
||||||
|
ErrorMessage = r.ErrorMessage,
|
||||||
|
ErrorDetail = r.ErrorDetail,
|
||||||
|
RequestSummary = r.RequestSummary,
|
||||||
|
ResponseSummary = r.ResponseSummary,
|
||||||
|
PayloadTruncated = r.PayloadTruncated,
|
||||||
|
Extra = r.Extra,
|
||||||
|
ForwardState = null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
|
||||||
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||||
@@ -627,8 +628,8 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var evt = BuildNotifyDeliverEvent(notification, now, AuditStatus.Attempted, errorMessage)
|
var evt = BuildNotifyDeliverEvent(
|
||||||
with { DurationMs = durationMs };
|
notification, now, AuditStatus.Attempted, errorMessage, durationMs);
|
||||||
await _auditWriter.WriteAsync(evt);
|
await _auditWriter.WriteAsync(evt);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -658,42 +659,41 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
Notification notification,
|
Notification notification,
|
||||||
DateTimeOffset now,
|
DateTimeOffset now,
|
||||||
AuditStatus status,
|
AuditStatus status,
|
||||||
string? errorMessage)
|
string? errorMessage,
|
||||||
|
int? durationMs = null)
|
||||||
{
|
{
|
||||||
Guid? correlationId = Guid.TryParse(notification.NotificationId, out var parsed)
|
Guid? correlationId = Guid.TryParse(notification.NotificationId, out var parsed)
|
||||||
? parsed
|
? parsed
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return new AuditEvent
|
return ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
channel: AuditChannel.Notification,
|
||||||
EventId = Guid.NewGuid(),
|
kind: AuditKind.NotifyDeliver,
|
||||||
OccurredAtUtc = now.UtcDateTime,
|
status: status,
|
||||||
Channel = AuditChannel.Notification,
|
occurredAtUtc: now.UtcDateTime,
|
||||||
Kind = AuditKind.NotifyDeliver,
|
|
||||||
CorrelationId = correlationId,
|
|
||||||
// Central dispatch — a system identity per the Actor-column spec;
|
// Central dispatch — a system identity per the Actor-column spec;
|
||||||
// there is no per-call authenticated user here. The originating
|
// there is no per-call authenticated user here. The originating
|
||||||
// script is still captured on SourceScript (and on the upstream
|
// script is still captured on SourceScript (and on the upstream
|
||||||
// NotifySend row).
|
// NotifySend row).
|
||||||
Actor = SystemActor,
|
actor: SystemActor,
|
||||||
SourceSiteId = notification.SourceSiteId,
|
target: notification.ListName,
|
||||||
SourceInstanceId = notification.SourceInstanceId,
|
correlationId: correlationId,
|
||||||
SourceScript = notification.SourceScript,
|
|
||||||
// ExecutionId (Audit Log #23): the originating script execution's id,
|
// ExecutionId (Audit Log #23): the originating script execution's id,
|
||||||
// carried from the site on NotificationSubmit and persisted on the
|
// carried from the site on NotificationSubmit and persisted on the
|
||||||
// Notification row. Echoing it here links the central NotifyDeliver
|
// Notification row. Echoing it here links the central NotifyDeliver
|
||||||
// rows to the site-emitted NotifySend row for the same run. Null when
|
// rows to the site-emitted NotifySend row for the same run. Null when
|
||||||
// the notification was raised outside a script execution.
|
// the notification was raised outside a script execution.
|
||||||
ExecutionId = notification.OriginExecutionId,
|
executionId: notification.OriginExecutionId,
|
||||||
// ParentExecutionId (Audit Log #23): the originating routed run's
|
// ParentExecutionId (Audit Log #23): the originating routed run's
|
||||||
// parent ExecutionId, carried from the site on NotificationSubmit and
|
// parent ExecutionId, carried from the site on NotificationSubmit and
|
||||||
// persisted on the Notification row. Echoing it here links the central
|
// persisted on the Notification row. Echoing it here links the central
|
||||||
// NotifyDeliver rows to the routed run's parent. Null for non-routed runs.
|
// NotifyDeliver rows to the routed run's parent. Null for non-routed runs.
|
||||||
ParentExecutionId = notification.OriginParentExecutionId,
|
parentExecutionId: notification.OriginParentExecutionId,
|
||||||
Target = notification.ListName,
|
sourceSiteId: notification.SourceSiteId,
|
||||||
Status = status,
|
sourceInstanceId: notification.SourceInstanceId,
|
||||||
ErrorMessage = errorMessage,
|
sourceScript: notification.SourceScript,
|
||||||
};
|
durationMs: durationMs,
|
||||||
|
errorMessage: errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ using System.Data;
|
|||||||
using System.Data.Common;
|
using System.Data.Common;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||||
|
|
||||||
@@ -473,40 +474,36 @@ internal sealed class AuditingDbCommand : DbCommand
|
|||||||
? $"{{\"op\":\"write\",\"rowsAffected\":{(rowsAffected ?? 0)}}}"
|
? $"{{\"op\":\"write\",\"rowsAffected\":{(rowsAffected ?? 0)}}}"
|
||||||
: $"{{\"op\":\"read\",\"rowsReturned\":{(rowsReturned ?? 0)}}}";
|
: $"{{\"op\":\"read\",\"rowsReturned\":{(rowsReturned ?? 0)}}}";
|
||||||
|
|
||||||
return new AuditEvent
|
return ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
channel: AuditChannel.DbOutbound,
|
||||||
EventId = Guid.NewGuid(),
|
kind: AuditKind.DbWrite,
|
||||||
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
status: status,
|
||||||
Channel = AuditChannel.DbOutbound,
|
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
||||||
Kind = AuditKind.DbWrite,
|
// Outbound channel: per the Audit Log Actor-column spec the actor is
|
||||||
|
// the calling script. Null when no single script owns the call
|
||||||
|
// (e.g. a shared script running inline).
|
||||||
|
actor: _sourceScript,
|
||||||
|
target: target,
|
||||||
// Audit Log #23: a sync one-shot DB write has no operation
|
// Audit Log #23: a sync one-shot DB write has no operation
|
||||||
// lifecycle, so CorrelationId is null. ExecutionId carries the
|
// lifecycle, so CorrelationId is null. ExecutionId carries the
|
||||||
// per-execution id so this row shares an id with the other sync
|
// per-execution id so this row shares an id with the other sync
|
||||||
// trust-boundary rows from the same script run.
|
// trust-boundary rows from the same script run.
|
||||||
CorrelationId = null,
|
correlationId: null,
|
||||||
ExecutionId = _executionId,
|
executionId: _executionId,
|
||||||
// Audit Log #23 (ParentExecutionId): the spawning execution's id;
|
// Audit Log #23 (ParentExecutionId): the spawning execution's id;
|
||||||
// null for non-routed runs.
|
// null for non-routed runs.
|
||||||
ParentExecutionId = _parentExecutionId,
|
parentExecutionId: _parentExecutionId,
|
||||||
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
||||||
SourceInstanceId = _instanceName,
|
sourceInstanceId: _instanceName,
|
||||||
SourceScript = _sourceScript,
|
sourceScript: _sourceScript,
|
||||||
// Outbound channel: per the Audit Log Actor-column spec the actor is
|
httpStatus: null,
|
||||||
// the calling script. Null when no single script owns the call
|
durationMs: durationMs,
|
||||||
// (e.g. a shared script running inline).
|
errorMessage: thrown?.Message,
|
||||||
Actor = _sourceScript,
|
errorDetail: thrown?.ToString(),
|
||||||
Target = target,
|
requestSummary: requestSummary,
|
||||||
Status = status,
|
responseSummary: null,
|
||||||
HttpStatus = null,
|
payloadTruncated: false,
|
||||||
DurationMs = durationMs,
|
extra: extra);
|
||||||
ErrorMessage = thrown?.Message,
|
|
||||||
ErrorDetail = thrown?.ToString(),
|
|
||||||
RequestSummary = requestSummary,
|
|
||||||
ResponseSummary = null,
|
|
||||||
PayloadTruncated = false,
|
|
||||||
Extra = extra,
|
|
||||||
ForwardState = AuditForwardState.Pending,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using System.Text.Json;
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
||||||
@@ -11,7 +10,9 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
|
||||||
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
|
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||||
@@ -735,29 +736,25 @@ public class ScriptRuntimeContext
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
telemetry = new CachedCallTelemetry(
|
telemetry = new CachedCallTelemetry(
|
||||||
Audit: new AuditEvent
|
Audit: ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
channel: AuditChannel.ApiOutbound,
|
||||||
EventId = Guid.NewGuid(),
|
kind: AuditKind.CachedSubmit,
|
||||||
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
status: AuditStatus.Submitted,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
||||||
Kind = AuditKind.CachedSubmit,
|
target: target,
|
||||||
// CorrelationId stays the per-operation lifecycle id
|
// CorrelationId stays the per-operation lifecycle id
|
||||||
// (TrackedOperationId); ExecutionId carries the
|
// (TrackedOperationId); ExecutionId carries the
|
||||||
// per-execution id shared across this script run.
|
// per-execution id shared across this script run.
|
||||||
CorrelationId = trackedId.Value,
|
correlationId: trackedId.Value,
|
||||||
ExecutionId = _executionId,
|
executionId: _executionId,
|
||||||
// Audit Log #23 (ParentExecutionId): the spawning
|
// Audit Log #23 (ParentExecutionId): the spawning
|
||||||
// execution's id; null for non-routed runs.
|
// execution's id; null for non-routed runs.
|
||||||
ParentExecutionId = _parentExecutionId,
|
parentExecutionId: _parentExecutionId,
|
||||||
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
||||||
SourceInstanceId = _instanceName,
|
sourceInstanceId: _instanceName,
|
||||||
SourceScript = _sourceScript,
|
sourceScript: _sourceScript,
|
||||||
Target = target,
|
|
||||||
Status = AuditStatus.Submitted,
|
|
||||||
// Submit precedes the call — request args only, no response yet.
|
// Submit precedes the call — request args only, no response yet.
|
||||||
RequestSummary = SerializeRequest(parameters),
|
requestSummary: SerializeRequest(parameters)),
|
||||||
ForwardState = AuditForwardState.Pending,
|
|
||||||
},
|
|
||||||
Operational: new SiteCallOperational(
|
Operational: new SiteCallOperational(
|
||||||
TrackedOperationId: trackedId,
|
TrackedOperationId: trackedId,
|
||||||
Channel: "ApiOutbound",
|
Channel: "ApiOutbound",
|
||||||
@@ -857,30 +854,26 @@ public class ScriptRuntimeContext
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
attempted = new CachedCallTelemetry(
|
attempted = new CachedCallTelemetry(
|
||||||
Audit: new AuditEvent
|
Audit: ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
channel: AuditChannel.ApiOutbound,
|
||||||
EventId = Guid.NewGuid(),
|
kind: AuditKind.ApiCallCached,
|
||||||
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
status: AuditStatus.Attempted,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
||||||
Kind = AuditKind.ApiCallCached,
|
target: target,
|
||||||
// CorrelationId = per-operation lifecycle id;
|
// CorrelationId = per-operation lifecycle id;
|
||||||
// ExecutionId = per-execution id for this script run.
|
// ExecutionId = per-execution id for this script run.
|
||||||
CorrelationId = trackedId.Value,
|
correlationId: trackedId.Value,
|
||||||
ExecutionId = _executionId,
|
executionId: _executionId,
|
||||||
// Audit Log #23 (ParentExecutionId): the spawning
|
// Audit Log #23 (ParentExecutionId): the spawning
|
||||||
// execution's id; null for non-routed runs.
|
// execution's id; null for non-routed runs.
|
||||||
ParentExecutionId = _parentExecutionId,
|
parentExecutionId: _parentExecutionId,
|
||||||
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
||||||
SourceInstanceId = _instanceName,
|
sourceInstanceId: _instanceName,
|
||||||
SourceScript = _sourceScript,
|
sourceScript: _sourceScript,
|
||||||
Target = target,
|
httpStatus: httpStatus,
|
||||||
Status = AuditStatus.Attempted,
|
errorMessage: result.Success ? null : result.ErrorMessage,
|
||||||
HttpStatus = httpStatus,
|
requestSummary: SerializeRequest(parameters),
|
||||||
ErrorMessage = result.Success ? null : result.ErrorMessage,
|
responseSummary: result.ResponseJson),
|
||||||
RequestSummary = SerializeRequest(parameters),
|
|
||||||
ResponseSummary = result.ResponseJson,
|
|
||||||
ForwardState = AuditForwardState.Pending,
|
|
||||||
},
|
|
||||||
Operational: new SiteCallOperational(
|
Operational: new SiteCallOperational(
|
||||||
TrackedOperationId: trackedId,
|
TrackedOperationId: trackedId,
|
||||||
Channel: "ApiOutbound",
|
Channel: "ApiOutbound",
|
||||||
@@ -929,30 +922,26 @@ public class ScriptRuntimeContext
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
resolve = new CachedCallTelemetry(
|
resolve = new CachedCallTelemetry(
|
||||||
Audit: new AuditEvent
|
Audit: ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
channel: AuditChannel.ApiOutbound,
|
||||||
EventId = Guid.NewGuid(),
|
kind: AuditKind.CachedResolve,
|
||||||
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
status: auditTerminalStatus,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
||||||
Kind = AuditKind.CachedResolve,
|
target: target,
|
||||||
// CorrelationId = per-operation lifecycle id;
|
// CorrelationId = per-operation lifecycle id;
|
||||||
// ExecutionId = per-execution id for this script run.
|
// ExecutionId = per-execution id for this script run.
|
||||||
CorrelationId = trackedId.Value,
|
correlationId: trackedId.Value,
|
||||||
ExecutionId = _executionId,
|
executionId: _executionId,
|
||||||
// Audit Log #23 (ParentExecutionId): the spawning
|
// Audit Log #23 (ParentExecutionId): the spawning
|
||||||
// execution's id; null for non-routed runs.
|
// execution's id; null for non-routed runs.
|
||||||
ParentExecutionId = _parentExecutionId,
|
parentExecutionId: _parentExecutionId,
|
||||||
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
||||||
SourceInstanceId = _instanceName,
|
sourceInstanceId: _instanceName,
|
||||||
SourceScript = _sourceScript,
|
sourceScript: _sourceScript,
|
||||||
Target = target,
|
httpStatus: httpStatus,
|
||||||
Status = auditTerminalStatus,
|
errorMessage: result.Success ? null : result.ErrorMessage,
|
||||||
HttpStatus = httpStatus,
|
requestSummary: SerializeRequest(parameters),
|
||||||
ErrorMessage = result.Success ? null : result.ErrorMessage,
|
responseSummary: result.ResponseJson),
|
||||||
RequestSummary = SerializeRequest(parameters),
|
|
||||||
ResponseSummary = result.ResponseJson,
|
|
||||||
ForwardState = AuditForwardState.Pending,
|
|
||||||
},
|
|
||||||
Operational: new SiteCallOperational(
|
Operational: new SiteCallOperational(
|
||||||
TrackedOperationId: trackedId,
|
TrackedOperationId: trackedId,
|
||||||
Channel: "ApiOutbound",
|
Channel: "ApiOutbound",
|
||||||
@@ -1112,44 +1101,40 @@ public class ScriptRuntimeContext
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new AuditEvent
|
return ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
channel: AuditChannel.ApiOutbound,
|
||||||
EventId = Guid.NewGuid(),
|
kind: AuditKind.ApiCall,
|
||||||
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
status: status,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
||||||
Kind = AuditKind.ApiCall,
|
// Outbound channel: per the Audit Log Actor-column spec the actor
|
||||||
|
// is the calling script. Null when no single script owns the call
|
||||||
|
// (e.g. a shared script running inline).
|
||||||
|
actor: _sourceScript,
|
||||||
|
target: $"{systemName}.{methodName}",
|
||||||
// Audit Log #23: a sync one-shot call has no operation
|
// Audit Log #23: a sync one-shot call has no operation
|
||||||
// lifecycle, so CorrelationId is null. ExecutionId carries the
|
// lifecycle, so CorrelationId is null. ExecutionId carries the
|
||||||
// per-execution id so all the sync ApiCall/DbWrite rows from
|
// per-execution id so all the sync ApiCall/DbWrite rows from
|
||||||
// one script run can be correlated together.
|
// one script run can be correlated together.
|
||||||
CorrelationId = null,
|
correlationId: null,
|
||||||
ExecutionId = _executionId,
|
executionId: _executionId,
|
||||||
// Audit Log #23 (ParentExecutionId): the spawning execution's
|
// Audit Log #23 (ParentExecutionId): the spawning execution's
|
||||||
// id; null for non-routed runs.
|
// id; null for non-routed runs.
|
||||||
ParentExecutionId = _parentExecutionId,
|
parentExecutionId: _parentExecutionId,
|
||||||
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
||||||
SourceInstanceId = _instanceName,
|
sourceInstanceId: _instanceName,
|
||||||
SourceScript = _sourceScript,
|
sourceScript: _sourceScript,
|
||||||
// Outbound channel: per the Audit Log Actor-column spec the actor
|
httpStatus: httpStatus,
|
||||||
// is the calling script. Null when no single script owns the call
|
durationMs: durationMs,
|
||||||
// (e.g. a shared script running inline).
|
errorMessage: errorMessage,
|
||||||
Actor = _sourceScript,
|
errorDetail: errorDetail,
|
||||||
Target = $"{systemName}.{methodName}",
|
|
||||||
Status = status,
|
|
||||||
HttpStatus = httpStatus,
|
|
||||||
DurationMs = durationMs,
|
|
||||||
ErrorMessage = errorMessage,
|
|
||||||
ErrorDetail = errorDetail,
|
|
||||||
// Payload capture: the request arguments and the response body.
|
// Payload capture: the request arguments and the response body.
|
||||||
// The audit writer's payload filter applies the configured size
|
// The audit writer's redactor applies the configured size cap and
|
||||||
// cap and header/secret redaction downstream — the emitter just
|
// header/secret redaction downstream — the emitter just hands
|
||||||
// hands over the raw values.
|
// over the raw values.
|
||||||
RequestSummary = SerializeRequest(parameters),
|
requestSummary: SerializeRequest(parameters),
|
||||||
ResponseSummary = result?.ResponseJson,
|
responseSummary: result?.ResponseJson,
|
||||||
PayloadTruncated = false,
|
payloadTruncated: false,
|
||||||
Extra = null,
|
extra: null);
|
||||||
ForwardState = AuditForwardState.Pending,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1383,26 +1368,22 @@ public class ScriptRuntimeContext
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
telemetry = new CachedCallTelemetry(
|
telemetry = new CachedCallTelemetry(
|
||||||
Audit: new AuditEvent
|
Audit: ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
channel: AuditChannel.DbOutbound,
|
||||||
EventId = Guid.NewGuid(),
|
kind: AuditKind.CachedSubmit,
|
||||||
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
status: AuditStatus.Submitted,
|
||||||
Channel = AuditChannel.DbOutbound,
|
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
||||||
Kind = AuditKind.CachedSubmit,
|
target: target,
|
||||||
// CorrelationId = per-operation lifecycle id
|
// CorrelationId = per-operation lifecycle id
|
||||||
// (TrackedOperationId); ExecutionId = per-execution id.
|
// (TrackedOperationId); ExecutionId = per-execution id.
|
||||||
CorrelationId = trackedId.Value,
|
correlationId: trackedId.Value,
|
||||||
ExecutionId = _executionId,
|
executionId: _executionId,
|
||||||
// Audit Log #23 (ParentExecutionId): the spawning
|
// Audit Log #23 (ParentExecutionId): the spawning
|
||||||
// execution's id; null for non-routed runs.
|
// execution's id; null for non-routed runs.
|
||||||
ParentExecutionId = _parentExecutionId,
|
parentExecutionId: _parentExecutionId,
|
||||||
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
||||||
SourceInstanceId = _instanceName,
|
sourceInstanceId: _instanceName,
|
||||||
SourceScript = _sourceScript,
|
sourceScript: _sourceScript),
|
||||||
Target = target,
|
|
||||||
Status = AuditStatus.Submitted,
|
|
||||||
ForwardState = AuditForwardState.Pending,
|
|
||||||
},
|
|
||||||
Operational: new SiteCallOperational(
|
Operational: new SiteCallOperational(
|
||||||
TrackedOperationId: trackedId,
|
TrackedOperationId: trackedId,
|
||||||
Channel: "DbOutbound",
|
Channel: "DbOutbound",
|
||||||
@@ -1830,42 +1811,38 @@ public class ScriptRuntimeContext
|
|||||||
body = body,
|
body = body,
|
||||||
});
|
});
|
||||||
|
|
||||||
evt = new AuditEvent
|
evt = ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
channel: AuditChannel.Notification,
|
||||||
EventId = Guid.NewGuid(),
|
kind: AuditKind.NotifySend,
|
||||||
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
status: AuditStatus.Submitted,
|
||||||
Channel = AuditChannel.Notification,
|
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
||||||
Kind = AuditKind.NotifySend,
|
|
||||||
// CorrelationId is the NotificationId-derived per-operation
|
|
||||||
// lifecycle id; ExecutionId carries the per-execution id.
|
|
||||||
CorrelationId = correlationId,
|
|
||||||
ExecutionId = _executionId,
|
|
||||||
// Audit Log #23 (ParentExecutionId): the spawning
|
|
||||||
// execution's id; null for non-routed runs.
|
|
||||||
ParentExecutionId = _parentExecutionId,
|
|
||||||
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
|
||||||
SourceInstanceId = _instanceName,
|
|
||||||
SourceScript = _sourceScript,
|
|
||||||
// Outbound channel: per the Audit Log Actor-column spec the
|
// Outbound channel: per the Audit Log Actor-column spec the
|
||||||
// actor is the calling script. Null when no single script
|
// actor is the calling script. Null when no single script
|
||||||
// owns the call (e.g. a shared script running inline).
|
// owns the call (e.g. a shared script running inline).
|
||||||
Actor = _sourceScript,
|
actor: _sourceScript,
|
||||||
Target = _listName,
|
target: _listName,
|
||||||
Status = AuditStatus.Submitted,
|
// CorrelationId is the NotificationId-derived per-operation
|
||||||
HttpStatus = null,
|
// lifecycle id; ExecutionId carries the per-execution id.
|
||||||
|
correlationId: correlationId,
|
||||||
|
executionId: _executionId,
|
||||||
|
// Audit Log #23 (ParentExecutionId): the spawning
|
||||||
|
// execution's id; null for non-routed runs.
|
||||||
|
parentExecutionId: _parentExecutionId,
|
||||||
|
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
||||||
|
sourceInstanceId: _instanceName,
|
||||||
|
sourceScript: _sourceScript,
|
||||||
|
httpStatus: null,
|
||||||
// Send is fire-and-forget from the script's perspective —
|
// Send is fire-and-forget from the script's perspective —
|
||||||
// the dispatcher (NotificationOutboxActor) times each
|
// the dispatcher (NotificationOutboxActor) times each
|
||||||
// delivery attempt and stamps DurationMs on its
|
// delivery attempt and stamps DurationMs on its
|
||||||
// NotifyDeliver(Attempted) rows.
|
// NotifyDeliver(Attempted) rows.
|
||||||
DurationMs = null,
|
durationMs: null,
|
||||||
ErrorMessage = null,
|
errorMessage: null,
|
||||||
ErrorDetail = null,
|
errorDetail: null,
|
||||||
RequestSummary = requestSummary,
|
requestSummary: requestSummary,
|
||||||
ResponseSummary = null,
|
responseSummary: null,
|
||||||
PayloadTruncated = false,
|
payloadTruncated: false,
|
||||||
Extra = null,
|
extra: null);
|
||||||
ForwardState = AuditForwardState.Pending,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
catch (Exception buildEx)
|
catch (Exception buildEx)
|
||||||
{
|
{
|
||||||
|
|||||||
+16
-16
@@ -4,6 +4,8 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||||
@@ -55,16 +57,14 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
var trackedId = trackedOperationId ?? TrackedOperationId.New();
|
var trackedId = trackedOperationId ?? TrackedOperationId.New();
|
||||||
var now = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
|
var now = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
var audit = new AuditEvent
|
var audit = ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: eventId ?? Guid.NewGuid(),
|
||||||
EventId = eventId ?? Guid.NewGuid(),
|
occurredAtUtc: now,
|
||||||
OccurredAtUtc = now,
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.CachedSubmit,
|
||||||
Kind = AuditKind.CachedSubmit,
|
status: auditStatus,
|
||||||
Status = auditStatus,
|
sourceSiteId: siteId,
|
||||||
SourceSiteId = siteId,
|
correlationId: trackedId.Value);
|
||||||
CorrelationId = trackedId.Value,
|
|
||||||
};
|
|
||||||
|
|
||||||
var siteCall = new SiteCall
|
var siteCall = new SiteCall
|
||||||
{
|
{
|
||||||
@@ -137,7 +137,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
|
|
||||||
// Verify rows landed in both tables.
|
// Verify rows landed in both tables.
|
||||||
await using var read = CreateReadContext();
|
await using var read = CreateReadContext();
|
||||||
var auditRow = await read.Set<AuditEvent>().SingleOrDefaultAsync(e => e.EventId == audit.EventId);
|
var auditRow = await read.Set<AuditLogRow>().SingleOrDefaultAsync(e => e.EventId == audit.EventId);
|
||||||
Assert.NotNull(auditRow);
|
Assert.NotNull(auditRow);
|
||||||
Assert.NotNull(auditRow!.IngestedAtUtc);
|
Assert.NotNull(auditRow!.IngestedAtUtc);
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
Assert.Equal(eventId, reply.AcceptedEventIds[0]);
|
Assert.Equal(eventId, reply.AcceptedEventIds[0]);
|
||||||
|
|
||||||
await using var read = CreateReadContext();
|
await using var read = CreateReadContext();
|
||||||
var auditCount = await read.Set<AuditEvent>().CountAsync(e => e.EventId == eventId);
|
var auditCount = await read.Set<AuditLogRow>().CountAsync(e => e.EventId == eventId);
|
||||||
Assert.Equal(1, auditCount);
|
Assert.Equal(1, auditCount);
|
||||||
|
|
||||||
var siteCallCount = await read.Set<SiteCall>()
|
var siteCallCount = await read.Set<SiteCall>()
|
||||||
@@ -221,7 +221,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
|
|
||||||
// Both audit rows exist.
|
// Both audit rows exist.
|
||||||
await using var read = CreateReadContext();
|
await using var read = CreateReadContext();
|
||||||
var auditRows = await read.Set<AuditEvent>()
|
var auditRows = await read.Set<AuditLogRow>()
|
||||||
.Where(e => e.SourceSiteId == siteId)
|
.Where(e => e.SourceSiteId == siteId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
Assert.Equal(2, auditRows.Count);
|
Assert.Equal(2, auditRows.Count);
|
||||||
@@ -256,7 +256,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
Assert.Empty(reply.AcceptedEventIds);
|
Assert.Empty(reply.AcceptedEventIds);
|
||||||
|
|
||||||
await using var read = CreateReadContext();
|
await using var read = CreateReadContext();
|
||||||
var auditRow = await read.Set<AuditEvent>().SingleOrDefaultAsync(e => e.EventId == audit.EventId);
|
var auditRow = await read.Set<AuditLogRow>().SingleOrDefaultAsync(e => e.EventId == audit.EventId);
|
||||||
Assert.Null(auditRow);
|
Assert.Null(auditRow);
|
||||||
|
|
||||||
var siteCallRow = await read.Set<SiteCall>()
|
var siteCallRow = await read.Set<SiteCall>()
|
||||||
@@ -287,7 +287,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
.SetEquals(reply.AcceptedEventIds.ToHashSet()));
|
.SetEquals(reply.AcceptedEventIds.ToHashSet()));
|
||||||
|
|
||||||
await using var read = CreateReadContext();
|
await using var read = CreateReadContext();
|
||||||
var auditCount = await read.Set<AuditEvent>().CountAsync(e => e.SourceSiteId == siteId);
|
var auditCount = await read.Set<AuditLogRow>().CountAsync(e => e.SourceSiteId == siteId);
|
||||||
Assert.Equal(5, auditCount);
|
Assert.Equal(5, auditCount);
|
||||||
|
|
||||||
var siteCallCount = await read.Set<SiteCall>().CountAsync(s => s.SourceSite == siteId);
|
var siteCallCount = await read.Set<SiteCall>().CountAsync(s => s.SourceSite == siteId);
|
||||||
@@ -329,7 +329,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
Assert.DoesNotContain(audit2.EventId, reply.AcceptedEventIds);
|
Assert.DoesNotContain(audit2.EventId, reply.AcceptedEventIds);
|
||||||
|
|
||||||
await using var read = CreateReadContext();
|
await using var read = CreateReadContext();
|
||||||
var auditRows = await read.Set<AuditEvent>().Where(e => e.SourceSiteId == siteId).ToListAsync();
|
var auditRows = await read.Set<AuditLogRow>().Where(e => e.SourceSiteId == siteId).ToListAsync();
|
||||||
Assert.Equal(2, auditRows.Count);
|
Assert.Equal(2, auditRows.Count);
|
||||||
Assert.DoesNotContain(auditRows, r => r.EventId == audit2.EventId);
|
Assert.DoesNotContain(auditRows, r => r.EventId == audit2.EventId);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ using Akka.TestKit.Xunit2;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
@@ -41,15 +42,13 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
private static string NewSiteId() =>
|
private static string NewSiteId() =>
|
||||||
"test-bundle-d2-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
"test-bundle-d2-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||||
|
|
||||||
private static AuditEvent NewEvent(string siteId, Guid? id = null) => new()
|
private static AuditEvent NewEvent(string siteId, Guid? id = null) => ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: id ?? Guid.NewGuid(),
|
||||||
EventId = id ?? Guid.NewGuid(),
|
occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||||
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.ApiCall,
|
||||||
Kind = AuditKind.ApiCall,
|
status: AuditStatus.Delivered,
|
||||||
Status = AuditStatus.Delivered,
|
sourceSiteId: siteId);
|
||||||
SourceSiteId = siteId,
|
|
||||||
};
|
|
||||||
|
|
||||||
private IActorRef CreateActor(IAuditLogRepository repository) =>
|
private IActorRef CreateActor(IAuditLogRepository repository) =>
|
||||||
Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
|
Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
|
||||||
@@ -76,7 +75,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
|
|
||||||
// Verify rows landed in MSSQL.
|
// Verify rows landed in MSSQL.
|
||||||
await using var readContext = CreateContext();
|
await using var readContext = CreateContext();
|
||||||
var rows = await readContext.Set<AuditEvent>()
|
var rows = await readContext.Set<AuditLogRow>()
|
||||||
.Where(e => e.SourceSiteId == siteId)
|
.Where(e => e.SourceSiteId == siteId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
Assert.Equal(5, rows.Count);
|
Assert.Equal(5, rows.Count);
|
||||||
@@ -115,7 +114,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
|
|
||||||
// Verify no double-insert.
|
// Verify no double-insert.
|
||||||
await using var readContext = CreateContext();
|
await using var readContext = CreateContext();
|
||||||
var count = await readContext.Set<AuditEvent>()
|
var count = await readContext.Set<AuditLogRow>()
|
||||||
.Where(e => e.SourceSiteId == siteId)
|
.Where(e => e.SourceSiteId == siteId)
|
||||||
.CountAsync();
|
.CountAsync();
|
||||||
Assert.Equal(3, count);
|
Assert.Equal(3, count);
|
||||||
@@ -141,7 +140,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
var after = DateTime.UtcNow.AddSeconds(1);
|
var after = DateTime.UtcNow.AddSeconds(1);
|
||||||
|
|
||||||
await using var readContext = CreateContext();
|
await using var readContext = CreateContext();
|
||||||
var rows = await readContext.Set<AuditEvent>()
|
var rows = await readContext.Set<AuditLogRow>()
|
||||||
.Where(e => e.SourceSiteId == siteId)
|
.Where(e => e.SourceSiteId == siteId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
@@ -178,7 +177,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
Assert.DoesNotContain(poisonId, reply.AcceptedEventIds);
|
Assert.DoesNotContain(poisonId, reply.AcceptedEventIds);
|
||||||
|
|
||||||
await using var readContext = CreateContext();
|
await using var readContext = CreateContext();
|
||||||
var rows = await readContext.Set<AuditEvent>()
|
var rows = await readContext.Set<AuditLogRow>()
|
||||||
.Where(e => e.SourceSiteId == siteId)
|
.Where(e => e.SourceSiteId == siteId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
Assert.Equal(4, rows.Count);
|
Assert.Equal(4, rows.Count);
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
@@ -272,24 +273,20 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
// * Jan partition (MAX = Jan 15) → older than threshold → PURGED
|
// * Jan partition (MAX = Jan 15) → older than threshold → PURGED
|
||||||
// * Apr partition (MAX = Apr 15) → newer than threshold → KEPT
|
// * Apr partition (MAX = Apr 15) → newer than threshold → KEPT
|
||||||
var siteId = "purge-e2e-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
var siteId = "purge-e2e-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||||
var janEvt = new AuditEvent
|
var janEvt = ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: Guid.NewGuid(),
|
||||||
EventId = Guid.NewGuid(),
|
occurredAtUtc: new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc),
|
||||||
OccurredAtUtc = new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc),
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.ApiCall,
|
||||||
Kind = AuditKind.ApiCall,
|
status: AuditStatus.Delivered,
|
||||||
Status = AuditStatus.Delivered,
|
sourceSiteId: siteId);
|
||||||
SourceSiteId = siteId,
|
var aprEvt = ScadaBridgeAuditEventFactory.Create(
|
||||||
};
|
eventId: Guid.NewGuid(),
|
||||||
var aprEvt = new AuditEvent
|
occurredAtUtc: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc),
|
||||||
{
|
channel: AuditChannel.ApiOutbound,
|
||||||
EventId = Guid.NewGuid(),
|
kind: AuditKind.ApiCall,
|
||||||
OccurredAtUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc),
|
status: AuditStatus.Delivered,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
sourceSiteId: siteId);
|
||||||
Kind = AuditKind.ApiCall,
|
|
||||||
Status = AuditStatus.Delivered,
|
|
||||||
SourceSiteId = siteId,
|
|
||||||
};
|
|
||||||
|
|
||||||
await using (var seedContext = CreateMsSqlContext())
|
await using (var seedContext = CreateMsSqlContext())
|
||||||
{
|
{
|
||||||
@@ -341,7 +338,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
// Settle: allow any in-flight tick to commit before reading.
|
// Settle: allow any in-flight tick to commit before reading.
|
||||||
await Task.Delay(TimeSpan.FromMilliseconds(500));
|
await Task.Delay(TimeSpan.FromMilliseconds(500));
|
||||||
await using var verifyContext = CreateMsSqlContext();
|
await using var verifyContext = CreateMsSqlContext();
|
||||||
var rows = await verifyContext.Set<AuditEvent>()
|
var rows = await verifyContext.Set<AuditLogRow>()
|
||||||
.Where(e => e.SourceSiteId == siteId)
|
.Where(e => e.SourceSiteId == siteId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
|||||||
+8
-10
@@ -3,7 +3,7 @@ using Akka.TestKit.Xunit2;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
@@ -22,14 +22,12 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class CentralAuditWriteFailuresTests : TestKit
|
public class CentralAuditWriteFailuresTests : TestKit
|
||||||
{
|
{
|
||||||
private static AuditEvent NewEvent() => new()
|
private static AuditEvent NewEvent() => ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: Guid.NewGuid(),
|
||||||
EventId = Guid.NewGuid(),
|
occurredAtUtc: DateTime.UtcNow,
|
||||||
OccurredAtUtc = DateTime.UtcNow,
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.ApiCall,
|
||||||
Kind = AuditKind.ApiCall,
|
status: AuditStatus.Delivered);
|
||||||
Status = AuditStatus.Delivered,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Repository stub that always throws on insert — exercises the failure
|
/// Repository stub that always throws on insert — exercises the failure
|
||||||
@@ -84,7 +82,7 @@ public class CentralAuditWriteFailuresTests : TestKit
|
|||||||
var writer = new CentralAuditWriter(
|
var writer = new CentralAuditWriter(
|
||||||
sp,
|
sp,
|
||||||
NullLogger<CentralAuditWriter>.Instance,
|
NullLogger<CentralAuditWriter>.Instance,
|
||||||
filter: null,
|
redactor: null,
|
||||||
failureCounter: counter);
|
failureCounter: counter);
|
||||||
|
|
||||||
// WriteAsync swallows the exception and increments the counter.
|
// WriteAsync swallows the exception and increments the counter.
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ using NSubstitute;
|
|||||||
using NSubstitute.ExceptionExtensions;
|
using NSubstitute.ExceptionExtensions;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
@@ -22,16 +23,16 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class CentralAuditWriterTests
|
public class CentralAuditWriterTests
|
||||||
{
|
{
|
||||||
private static AuditEvent NewEvent(Guid? eventId = null) => new()
|
// C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory.
|
||||||
{
|
private static AuditEvent NewEvent(Guid? eventId = null) =>
|
||||||
EventId = eventId ?? Guid.NewGuid(),
|
ScadaBridgeAuditEventFactory.Create(
|
||||||
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
channel: AuditChannel.Notification,
|
||||||
Channel = AuditChannel.Notification,
|
kind: AuditKind.NotifyDeliver,
|
||||||
Kind = AuditKind.NotifyDeliver,
|
status: AuditStatus.Attempted,
|
||||||
Status = AuditStatus.Attempted,
|
eventId: eventId ?? Guid.NewGuid(),
|
||||||
CorrelationId = Guid.NewGuid(),
|
occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||||
Target = "ops-team",
|
target: "ops-team",
|
||||||
};
|
correlationId: Guid.NewGuid());
|
||||||
|
|
||||||
private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriter()
|
private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriter()
|
||||||
{
|
{
|
||||||
@@ -65,10 +66,12 @@ public class CentralAuditWriterTests
|
|||||||
|
|
||||||
var after = DateTime.UtcNow;
|
var after = DateTime.UtcNow;
|
||||||
await repo.Received(1).InsertIfNotExistsAsync(
|
await repo.Received(1).InsertIfNotExistsAsync(
|
||||||
|
// C3 (Task 2.5): IngestedAtUtc now rides in DetailsJson on the canonical
|
||||||
|
// record — read it back via the decomposed row view.
|
||||||
Arg.Is<AuditEvent>(e =>
|
Arg.Is<AuditEvent>(e =>
|
||||||
e.IngestedAtUtc != null &&
|
e.AsRow().IngestedAtUtc != null &&
|
||||||
e.IngestedAtUtc >= before &&
|
e.AsRow().IngestedAtUtc >= before &&
|
||||||
e.IngestedAtUtc <= after),
|
e.AsRow().IngestedAtUtc <= after),
|
||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +141,7 @@ public class CentralAuditWriterTests
|
|||||||
var writer = new CentralAuditWriter(
|
var writer = new CentralAuditWriter(
|
||||||
provider,
|
provider,
|
||||||
NullLogger<CentralAuditWriter>.Instance,
|
NullLogger<CentralAuditWriter>.Instance,
|
||||||
filter: null,
|
redactor: null,
|
||||||
failureCounter: null,
|
failureCounter: null,
|
||||||
nodeIdentity: nodeIdentity);
|
nodeIdentity: nodeIdentity);
|
||||||
return (writer, repo);
|
return (writer, repo);
|
||||||
|
|||||||
+10
-11
@@ -5,7 +5,8 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
@@ -38,15 +39,13 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
private static AuditEvent NewEvent(
|
private static AuditEvent NewEvent(
|
||||||
string siteId,
|
string siteId,
|
||||||
DateTime? occurredAt = null,
|
DateTime? occurredAt = null,
|
||||||
Guid? id = null) => new()
|
Guid? id = null) => ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: id ?? Guid.NewGuid(),
|
||||||
EventId = id ?? Guid.NewGuid(),
|
occurredAtUtc: occurredAt ?? new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||||
OccurredAtUtc = occurredAt ?? new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.ApiCall,
|
||||||
Kind = AuditKind.ApiCall,
|
status: AuditStatus.Delivered,
|
||||||
Status = AuditStatus.Delivered,
|
sourceSiteId: siteId);
|
||||||
SourceSiteId = siteId,
|
|
||||||
};
|
|
||||||
|
|
||||||
private static SiteAuditReconciliationOptions FastTickOptions(
|
private static SiteAuditReconciliationOptions FastTickOptions(
|
||||||
int batchSize = 256,
|
int batchSize = 256,
|
||||||
@@ -312,7 +311,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
// exist in MSSQL alongside the pre-existing one — InsertIfNotExistsAsync
|
// exist in MSSQL alongside the pre-existing one — InsertIfNotExistsAsync
|
||||||
// is first-write-wins on EventId.
|
// is first-write-wins on EventId.
|
||||||
await using var read = CreateContext();
|
await using var read = CreateContext();
|
||||||
var rows = await read.Set<AuditEvent>()
|
var rows = await read.Set<AuditLogRow>()
|
||||||
.Where(e => e.SourceSiteId == siteId)
|
.Where(e => e.SourceSiteId == siteId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
Assert.Equal(2, rows.Count);
|
Assert.Equal(2, rows.Count);
|
||||||
|
|||||||
+28
-32
@@ -4,8 +4,8 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
|
||||||
@@ -95,25 +95,23 @@ public class AuditLogOptionsBindingTests
|
|||||||
// PayloadTruncated flips to true.
|
// PayloadTruncated flips to true.
|
||||||
var initial = new AuditLogOptions { DefaultCapBytes = 4096 };
|
var initial = new AuditLogOptions { DefaultCapBytes = 4096 };
|
||||||
var monitor = new TestOptionsMonitor<AuditLogOptions>(initial);
|
var monitor = new TestOptionsMonitor<AuditLogOptions>(initial);
|
||||||
var filter = new DefaultAuditPayloadFilter(
|
var filter = new ScadaBridgeAuditRedactor(
|
||||||
monitor,
|
monitor,
|
||||||
NullLogger<DefaultAuditPayloadFilter>.Instance);
|
NullLogger<ScadaBridgeAuditRedactor>.Instance);
|
||||||
|
|
||||||
var body = new string('x', 5 * 1024);
|
var body = new string('x', 5 * 1024);
|
||||||
var evt = new AuditEvent
|
var evt = ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: Guid.NewGuid(),
|
||||||
EventId = Guid.NewGuid(),
|
occurredAtUtc: DateTime.UtcNow,
|
||||||
OccurredAtUtc = DateTime.UtcNow,
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.ApiCall,
|
||||||
Kind = AuditKind.ApiCall,
|
status: AuditStatus.Delivered,
|
||||||
Status = AuditStatus.Delivered,
|
requestSummary: body);
|
||||||
RequestSummary = body,
|
|
||||||
};
|
|
||||||
|
|
||||||
var resultBefore = filter.Apply(evt);
|
var resultBefore = filter.Apply(evt);
|
||||||
Assert.True(resultBefore.PayloadTruncated, "5KB body at 4096 cap must be truncated");
|
Assert.True(resultBefore.AsRow().PayloadTruncated, "5KB body at 4096 cap must be truncated");
|
||||||
Assert.NotNull(resultBefore.RequestSummary);
|
Assert.NotNull(resultBefore.AsRow().RequestSummary);
|
||||||
Assert.True(Encoding.UTF8.GetByteCount(resultBefore.RequestSummary!) <= 4096);
|
Assert.True(Encoding.UTF8.GetByteCount(resultBefore.AsRow().RequestSummary!) <= 4096);
|
||||||
|
|
||||||
// Reload: cap raised to 16384 — next event must NOT truncate. This is
|
// Reload: cap raised to 16384 — next event must NOT truncate. This is
|
||||||
// the M5-T8 contract: the filter sees the new value on the very next
|
// the M5-T8 contract: the filter sees the new value on the very next
|
||||||
@@ -121,8 +119,8 @@ public class AuditLogOptionsBindingTests
|
|||||||
monitor.Set(new AuditLogOptions { DefaultCapBytes = 16384 });
|
monitor.Set(new AuditLogOptions { DefaultCapBytes = 16384 });
|
||||||
|
|
||||||
var resultAfter = filter.Apply(evt);
|
var resultAfter = filter.Apply(evt);
|
||||||
Assert.False(resultAfter.PayloadTruncated, "5KB body at 16384 cap must NOT be truncated");
|
Assert.False(resultAfter.AsRow().PayloadTruncated, "5KB body at 16384 cap must NOT be truncated");
|
||||||
Assert.Equal(body, resultAfter.RequestSummary);
|
Assert.Equal(body, resultAfter.AsRow().RequestSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -133,23 +131,21 @@ public class AuditLogOptionsBindingTests
|
|||||||
// process restart. Pre-reload: no redactor, hunter2 survives. After
|
// process restart. Pre-reload: no redactor, hunter2 survives. After
|
||||||
// reload: hunter2 redacted.
|
// reload: hunter2 redacted.
|
||||||
var monitor = new TestOptionsMonitor<AuditLogOptions>(new AuditLogOptions());
|
var monitor = new TestOptionsMonitor<AuditLogOptions>(new AuditLogOptions());
|
||||||
var filter = new DefaultAuditPayloadFilter(
|
var filter = new ScadaBridgeAuditRedactor(
|
||||||
monitor,
|
monitor,
|
||||||
NullLogger<DefaultAuditPayloadFilter>.Instance);
|
NullLogger<ScadaBridgeAuditRedactor>.Instance);
|
||||||
|
|
||||||
const string body = "{\"user\":\"alice\",\"password\":\"hunter2\"}";
|
const string body = "{\"user\":\"alice\",\"password\":\"hunter2\"}";
|
||||||
var evt = new AuditEvent
|
var evt = ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: Guid.NewGuid(),
|
||||||
EventId = Guid.NewGuid(),
|
occurredAtUtc: DateTime.UtcNow,
|
||||||
OccurredAtUtc = DateTime.UtcNow,
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.ApiCall,
|
||||||
Kind = AuditKind.ApiCall,
|
status: AuditStatus.Delivered,
|
||||||
Status = AuditStatus.Delivered,
|
requestSummary: body);
|
||||||
RequestSummary = body,
|
|
||||||
};
|
|
||||||
|
|
||||||
var before = filter.Apply(evt);
|
var before = filter.Apply(evt);
|
||||||
Assert.Contains("hunter2", before.RequestSummary!);
|
Assert.Contains("hunter2", before.AsRow().RequestSummary!);
|
||||||
|
|
||||||
monitor.Set(new AuditLogOptions
|
monitor.Set(new AuditLogOptions
|
||||||
{
|
{
|
||||||
@@ -157,8 +153,8 @@ public class AuditLogOptionsBindingTests
|
|||||||
});
|
});
|
||||||
|
|
||||||
var after = filter.Apply(evt);
|
var after = filter.Apply(evt);
|
||||||
Assert.DoesNotContain("hunter2", after.RequestSummary!);
|
Assert.DoesNotContain("hunter2", after.AsRow().RequestSummary!);
|
||||||
Assert.Contains("<redacted>", after.RequestSummary!);
|
Assert.Contains("<redacted>", after.AsRow().RequestSummary!);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
+3
-1
@@ -11,7 +11,9 @@ using Microsoft.Extensions.Hosting;
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
|
|||||||
+17
-17
@@ -1,7 +1,10 @@
|
|||||||
using Akka.TestKit.Xunit2;
|
using Akka.TestKit.Xunit2;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
@@ -53,20 +56,17 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
|
|||||||
private static CachedCallTelemetry SubmitPacket(
|
private static CachedCallTelemetry SubmitPacket(
|
||||||
TrackedOperationId id, string siteId, DateTime nowUtc, string target = "ERP.GetOrder") =>
|
TrackedOperationId id, string siteId, DateTime nowUtc, string target = "ERP.GetOrder") =>
|
||||||
new(
|
new(
|
||||||
Audit: new AuditEvent
|
Audit: ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: Guid.NewGuid(),
|
||||||
EventId = Guid.NewGuid(),
|
occurredAtUtc: nowUtc,
|
||||||
OccurredAtUtc = nowUtc,
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.CachedSubmit,
|
||||||
Kind = AuditKind.CachedSubmit,
|
correlationId: id.Value,
|
||||||
CorrelationId = id.Value,
|
sourceSiteId: siteId,
|
||||||
SourceSiteId = siteId,
|
sourceInstanceId: "Plant.Pump42",
|
||||||
SourceInstanceId = "Plant.Pump42",
|
sourceScript: "ScriptActor:doStuff",
|
||||||
SourceScript = "ScriptActor:doStuff",
|
target: target,
|
||||||
Target = target,
|
status: AuditStatus.Submitted),
|
||||||
Status = AuditStatus.Submitted,
|
|
||||||
ForwardState = AuditForwardState.Pending,
|
|
||||||
},
|
|
||||||
Operational: new SiteCallOperational(
|
Operational: new SiteCallOperational(
|
||||||
TrackedOperationId: id,
|
TrackedOperationId: id,
|
||||||
Channel: "ApiOutbound",
|
Channel: "ApiOutbound",
|
||||||
@@ -149,7 +149,7 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
|
|||||||
// 1 Submit + 2 transient Attempted + 1 terminal Attempted + 1
|
// 1 Submit + 2 transient Attempted + 1 terminal Attempted + 1
|
||||||
// CachedResolve = 5 audit rows. The plan allows 4-5; this is the
|
// CachedResolve = 5 audit rows. The plan allows 4-5; this is the
|
||||||
// happy path emitting exactly 5.
|
// happy path emitting exactly 5.
|
||||||
var auditRows = await read.Set<AuditEvent>()
|
var auditRows = await read.Set<AuditLogRow>()
|
||||||
.Where(e => e.SourceSiteId == siteId)
|
.Where(e => e.SourceSiteId == siteId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
Assert.InRange(auditRows.Count, 4, 5);
|
Assert.InRange(auditRows.Count, 4, 5);
|
||||||
@@ -215,7 +215,7 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
|
|||||||
Assert.NotNull(siteCall.TerminalAtUtc);
|
Assert.NotNull(siteCall.TerminalAtUtc);
|
||||||
|
|
||||||
// Terminal audit row should also be Parked.
|
// Terminal audit row should also be Parked.
|
||||||
var resolve = await read.Set<AuditEvent>()
|
var resolve = await read.Set<AuditLogRow>()
|
||||||
.Where(e => e.SourceSiteId == siteId && e.Kind == AuditKind.CachedResolve)
|
.Where(e => e.SourceSiteId == siteId && e.Kind == AuditKind.CachedResolve)
|
||||||
.SingleAsync();
|
.SingleAsync();
|
||||||
Assert.Equal(AuditStatus.Parked, resolve.Status);
|
Assert.Equal(AuditStatus.Parked, resolve.Status);
|
||||||
@@ -255,7 +255,7 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
|
|||||||
Assert.NotNull(siteCall.TerminalAtUtc);
|
Assert.NotNull(siteCall.TerminalAtUtc);
|
||||||
|
|
||||||
// 1 Submit + 1 Attempted + 1 CachedResolve = 3 audit rows.
|
// 1 Submit + 1 Attempted + 1 CachedResolve = 3 audit rows.
|
||||||
var auditRows = await read.Set<AuditEvent>()
|
var auditRows = await read.Set<AuditLogRow>()
|
||||||
.Where(e => e.SourceSiteId == siteId)
|
.Where(e => e.SourceSiteId == siteId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
Assert.Equal(3, auditRows.Count);
|
Assert.Equal(3, auditRows.Count);
|
||||||
|
|||||||
+16
-16
@@ -1,7 +1,10 @@
|
|||||||
using Akka.TestKit.Xunit2;
|
using Akka.TestKit.Xunit2;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
@@ -42,20 +45,17 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
private static CachedCallTelemetry DbSubmitPacket(
|
private static CachedCallTelemetry DbSubmitPacket(
|
||||||
TrackedOperationId id, string siteId, DateTime nowUtc, string target = "OperationsDb.UpdateOrder") =>
|
TrackedOperationId id, string siteId, DateTime nowUtc, string target = "OperationsDb.UpdateOrder") =>
|
||||||
new(
|
new(
|
||||||
Audit: new AuditEvent
|
Audit: ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: Guid.NewGuid(),
|
||||||
EventId = Guid.NewGuid(),
|
occurredAtUtc: nowUtc,
|
||||||
OccurredAtUtc = nowUtc,
|
channel: AuditChannel.DbOutbound,
|
||||||
Channel = AuditChannel.DbOutbound,
|
kind: AuditKind.CachedSubmit,
|
||||||
Kind = AuditKind.CachedSubmit,
|
correlationId: id.Value,
|
||||||
CorrelationId = id.Value,
|
sourceSiteId: siteId,
|
||||||
SourceSiteId = siteId,
|
sourceInstanceId: "Plant.Pump42",
|
||||||
SourceInstanceId = "Plant.Pump42",
|
sourceScript: "ScriptActor:doStuff",
|
||||||
SourceScript = "ScriptActor:doStuff",
|
target: target,
|
||||||
Target = target,
|
status: AuditStatus.Submitted),
|
||||||
Status = AuditStatus.Submitted,
|
|
||||||
ForwardState = AuditForwardState.Pending,
|
|
||||||
},
|
|
||||||
Operational: new SiteCallOperational(
|
Operational: new SiteCallOperational(
|
||||||
TrackedOperationId: id,
|
TrackedOperationId: id,
|
||||||
Channel: "DbOutbound",
|
Channel: "DbOutbound",
|
||||||
@@ -122,7 +122,7 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
Assert.Equal(0, siteCall.RetryCount);
|
Assert.Equal(0, siteCall.RetryCount);
|
||||||
Assert.NotNull(siteCall.TerminalAtUtc);
|
Assert.NotNull(siteCall.TerminalAtUtc);
|
||||||
|
|
||||||
var auditRows = await read.Set<AuditEvent>()
|
var auditRows = await read.Set<AuditLogRow>()
|
||||||
.Where(e => e.SourceSiteId == siteId)
|
.Where(e => e.SourceSiteId == siteId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
Assert.Equal(3, auditRows.Count);
|
Assert.Equal(3, auditRows.Count);
|
||||||
@@ -182,7 +182,7 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
Assert.Equal("Parked", siteCall.Status);
|
Assert.Equal("Parked", siteCall.Status);
|
||||||
Assert.NotNull(siteCall.TerminalAtUtc);
|
Assert.NotNull(siteCall.TerminalAtUtc);
|
||||||
|
|
||||||
var resolve = await read.Set<AuditEvent>()
|
var resolve = await read.Set<AuditLogRow>()
|
||||||
.Where(e => e.SourceSiteId == siteId && e.Kind == AuditKind.CachedResolve)
|
.Where(e => e.SourceSiteId == siteId && e.Kind == AuditKind.CachedResolve)
|
||||||
.SingleAsync();
|
.SingleAsync();
|
||||||
Assert.Equal(AuditStatus.Parked, resolve.Status);
|
Assert.Equal(AuditStatus.Parked, resolve.Status);
|
||||||
|
|||||||
+16
-16
@@ -1,7 +1,10 @@
|
|||||||
using Akka.TestKit.Xunit2;
|
using Akka.TestKit.Xunit2;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
@@ -54,20 +57,17 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
{
|
{
|
||||||
var dto = new CachedTelemetryPacket
|
var dto = new CachedTelemetryPacket
|
||||||
{
|
{
|
||||||
AuditEvent = AuditEventDtoMapper.ToDto(new AuditEvent
|
AuditEvent = AuditEventDtoMapper.ToDto(ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: eventId,
|
||||||
EventId = eventId,
|
occurredAtUtc: nowUtc,
|
||||||
OccurredAtUtc = nowUtc,
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: kind,
|
||||||
Kind = kind,
|
correlationId: trackedId.Value,
|
||||||
CorrelationId = trackedId.Value,
|
sourceSiteId: siteId,
|
||||||
SourceSiteId = siteId,
|
target: "ERP.GetOrder",
|
||||||
Target = "ERP.GetOrder",
|
status: auditStatus,
|
||||||
Status = auditStatus,
|
httpStatus: httpStatus,
|
||||||
HttpStatus = httpStatus,
|
errorMessage: lastError)),
|
||||||
ErrorMessage = lastError,
|
|
||||||
ForwardState = AuditForwardState.Pending,
|
|
||||||
}),
|
|
||||||
Operational = new SiteCallOperationalDto
|
Operational = new SiteCallOperationalDto
|
||||||
{
|
{
|
||||||
TrackedOperationId = trackedId.Value.ToString("D"),
|
TrackedOperationId = trackedId.Value.ToString("D"),
|
||||||
@@ -131,7 +131,7 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
await using var read = harness.CreateReadContext();
|
await using var read = harness.CreateReadContext();
|
||||||
|
|
||||||
// AuditLog: exactly ONE row for the EventId (insert-if-not-exists).
|
// AuditLog: exactly ONE row for the EventId (insert-if-not-exists).
|
||||||
var auditCount = await read.Set<AuditEvent>()
|
var auditCount = await read.Set<AuditLogRow>()
|
||||||
.CountAsync(e => e.EventId == eventId);
|
.CountAsync(e => e.EventId == eventId);
|
||||||
Assert.Equal(1, auditCount);
|
Assert.Equal(1, auditCount);
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
// AuditLog: TWO rows now exist for this lifecycle — the Submit and
|
// AuditLog: TWO rows now exist for this lifecycle — the Submit and
|
||||||
// the Attempted. Their order is by OccurredAtUtc; the test doesn't
|
// the Attempted. Their order is by OccurredAtUtc; the test doesn't
|
||||||
// assert ordering, only count + correlation.
|
// assert ordering, only count + correlation.
|
||||||
var auditRows = await read.Set<AuditEvent>()
|
var auditRows = await read.Set<AuditLogRow>()
|
||||||
.Where(e => e.SourceSiteId == siteId)
|
.Where(e => e.SourceSiteId == siteId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
Assert.Equal(2, auditRows.Count);
|
Assert.Equal(2, auditRows.Count);
|
||||||
|
|||||||
+17
-17
@@ -220,17 +220,17 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||||||
new AuditLogPaging(PageSize: 10));
|
new AuditLogPaging(PageSize: 10));
|
||||||
var evt = Assert.Single(rows);
|
var evt = Assert.Single(rows);
|
||||||
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
|
Assert.Equal(AuditChannel.DbOutbound, evt.AsRow().Channel);
|
||||||
Assert.Equal(AuditKind.DbWrite, evt.Kind);
|
Assert.Equal(AuditKind.DbWrite, evt.AsRow().Kind);
|
||||||
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
Assert.Equal(AuditStatus.Delivered, evt.AsRow().Status);
|
||||||
Assert.Equal(siteId, evt.SourceSiteId);
|
Assert.Equal(siteId, evt.AsRow().SourceSiteId);
|
||||||
Assert.Equal(InstanceName, evt.SourceInstanceId);
|
Assert.Equal(InstanceName, evt.AsRow().SourceInstanceId);
|
||||||
Assert.Equal(SourceScript, evt.SourceScript);
|
Assert.Equal(SourceScript, evt.AsRow().SourceScript);
|
||||||
Assert.NotNull(evt.Extra);
|
Assert.NotNull(evt.AsRow().Extra);
|
||||||
Assert.Contains("\"op\":\"write\"", evt.Extra);
|
Assert.Contains("\"op\":\"write\"", evt.AsRow().Extra);
|
||||||
Assert.Contains("\"rowsAffected\":1", evt.Extra);
|
Assert.Contains("\"rowsAffected\":1", evt.AsRow().Extra);
|
||||||
// Central stamps IngestedAtUtc; the site never sets it.
|
// Central stamps IngestedAtUtc; the site never sets it.
|
||||||
Assert.NotNull(evt.IngestedAtUtc);
|
Assert.NotNull(evt.AsRow().IngestedAtUtc);
|
||||||
Assert.StartsWith(ConnectionName, evt.Target);
|
Assert.StartsWith(ConnectionName, evt.Target);
|
||||||
}, TimeSpan.FromSeconds(15));
|
}, TimeSpan.FromSeconds(15));
|
||||||
}
|
}
|
||||||
@@ -288,13 +288,13 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||||||
new AuditLogPaging(PageSize: 10));
|
new AuditLogPaging(PageSize: 10));
|
||||||
var evt = Assert.Single(rows);
|
var evt = Assert.Single(rows);
|
||||||
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
|
Assert.Equal(AuditChannel.DbOutbound, evt.AsRow().Channel);
|
||||||
Assert.Equal(AuditKind.DbWrite, evt.Kind);
|
Assert.Equal(AuditKind.DbWrite, evt.AsRow().Kind);
|
||||||
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
Assert.Equal(AuditStatus.Delivered, evt.AsRow().Status);
|
||||||
Assert.NotNull(evt.Extra);
|
Assert.NotNull(evt.AsRow().Extra);
|
||||||
Assert.Contains("\"op\":\"read\"", evt.Extra);
|
Assert.Contains("\"op\":\"read\"", evt.AsRow().Extra);
|
||||||
Assert.Contains("\"rowsReturned\":2", evt.Extra);
|
Assert.Contains("\"rowsReturned\":2", evt.AsRow().Extra);
|
||||||
Assert.NotNull(evt.IngestedAtUtc);
|
Assert.NotNull(evt.AsRow().IngestedAtUtc);
|
||||||
}, TimeSpan.FromSeconds(15));
|
}, TimeSpan.FromSeconds(15));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -219,18 +219,18 @@ public class ExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMigration
|
|||||||
// core promise of the per-run correlation value.
|
// core promise of the per-run correlation value.
|
||||||
Assert.All(rows, r =>
|
Assert.All(rows, r =>
|
||||||
{
|
{
|
||||||
Assert.NotNull(r.ExecutionId);
|
Assert.NotNull(r.AsRow().ExecutionId);
|
||||||
Assert.Equal(executionId, r.ExecutionId);
|
Assert.Equal(executionId, r.AsRow().ExecutionId);
|
||||||
Assert.Equal(siteId, r.SourceSiteId);
|
Assert.Equal(siteId, r.AsRow().SourceSiteId);
|
||||||
// Central stamps IngestedAtUtc; the site never sets it.
|
// Central stamps IngestedAtUtc; the site never sets it.
|
||||||
Assert.NotNull(r.IngestedAtUtc);
|
Assert.NotNull(r.AsRow().IngestedAtUtc);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The two rows are the two distinct trust-boundary actions — one
|
// The two rows are the two distinct trust-boundary actions — one
|
||||||
// outbound API call and one outbound DB write — proving the shared
|
// outbound API call and one outbound DB write — proving the shared
|
||||||
// id spans different channels, not two rows of the same action.
|
// id spans different channels, not two rows of the same action.
|
||||||
Assert.Single(rows, r => r.Channel == AuditChannel.ApiOutbound && r.Kind == AuditKind.ApiCall);
|
Assert.Single(rows, r => r.AsRow().Channel == AuditChannel.ApiOutbound && r.AsRow().Kind == AuditKind.ApiCall);
|
||||||
Assert.Single(rows, r => r.Channel == AuditChannel.DbOutbound && r.Kind == AuditKind.DbWrite);
|
Assert.Single(rows, r => r.AsRow().Channel == AuditChannel.DbOutbound && r.AsRow().Kind == AuditKind.DbWrite);
|
||||||
}, TimeSpan.FromSeconds(15));
|
}, TimeSpan.FromSeconds(15));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
@@ -223,15 +223,13 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
Assert.Equal(System.Net.HttpStatusCode.OK, resp.StatusCode);
|
Assert.Equal(System.Net.HttpStatusCode.OK, resp.StatusCode);
|
||||||
|
|
||||||
var evt = await AwaitOneAsync(methodName);
|
var evt = await AwaitOneAsync(methodName);
|
||||||
Assert.Equal(AuditChannel.ApiInbound, evt.Channel);
|
Assert.Equal(AuditChannel.ApiInbound, evt.AsRow().Channel);
|
||||||
Assert.Equal(AuditKind.InboundRequest, evt.Kind);
|
Assert.Equal(AuditKind.InboundRequest, evt.AsRow().Kind);
|
||||||
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
Assert.Equal(AuditStatus.Delivered, evt.AsRow().Status);
|
||||||
Assert.Equal(200, evt.HttpStatus);
|
Assert.Equal(200, evt.AsRow().HttpStatus);
|
||||||
Assert.Equal("integration-svc", evt.Actor);
|
Assert.Equal("integration-svc", evt.Actor);
|
||||||
// Central direct-write — no site-local forward state (alog.md §6).
|
|
||||||
Assert.Null(evt.ForwardState);
|
|
||||||
// IngestedAtUtc stamped by the central writer.
|
// IngestedAtUtc stamped by the central writer.
|
||||||
Assert.NotNull(evt.IngestedAtUtc);
|
Assert.NotNull(evt.AsRow().IngestedAtUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
@@ -257,13 +255,15 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, resp.StatusCode);
|
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||||
|
|
||||||
var evt = await AwaitOneAsync(methodName);
|
var evt = await AwaitOneAsync(methodName);
|
||||||
Assert.Equal(AuditChannel.ApiInbound, evt.Channel);
|
Assert.Equal(AuditChannel.ApiInbound, evt.AsRow().Channel);
|
||||||
Assert.Equal(AuditKind.InboundAuthFailure, evt.Kind);
|
Assert.Equal(AuditKind.InboundAuthFailure, evt.AsRow().Kind);
|
||||||
Assert.Equal(AuditStatus.Failed, evt.Status);
|
Assert.Equal(AuditStatus.Failed, evt.AsRow().Status);
|
||||||
Assert.Equal(401, evt.HttpStatus);
|
Assert.Equal(401, evt.AsRow().HttpStatus);
|
||||||
// Never echo back an unauthenticated principal — middleware suppresses
|
// Never echo back an unauthenticated principal — middleware suppresses
|
||||||
// the framework user resolution on 401/403 paths.
|
// the framework user resolution on 401/403 paths. C3 (Task 2.5): the
|
||||||
Assert.Null(evt.Actor);
|
// canonical Actor is a non-null string (empty when absent); the row view
|
||||||
|
// maps empty → null, preserving the "no principal" assertion.
|
||||||
|
Assert.Null(evt.AsRow().Actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
@@ -290,10 +290,10 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
Assert.Equal(System.Net.HttpStatusCode.InternalServerError, resp.StatusCode);
|
Assert.Equal(System.Net.HttpStatusCode.InternalServerError, resp.StatusCode);
|
||||||
|
|
||||||
var evt = await AwaitOneAsync(methodName);
|
var evt = await AwaitOneAsync(methodName);
|
||||||
Assert.Equal(AuditChannel.ApiInbound, evt.Channel);
|
Assert.Equal(AuditChannel.ApiInbound, evt.AsRow().Channel);
|
||||||
Assert.Equal(AuditKind.InboundRequest, evt.Kind);
|
Assert.Equal(AuditKind.InboundRequest, evt.AsRow().Kind);
|
||||||
Assert.Equal(AuditStatus.Failed, evt.Status);
|
Assert.Equal(AuditStatus.Failed, evt.AsRow().Status);
|
||||||
Assert.Equal(500, evt.HttpStatus);
|
Assert.Equal(500, evt.AsRow().HttpStatus);
|
||||||
Assert.Equal("integration-svc", evt.Actor);
|
Assert.Equal("integration-svc", evt.Actor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -1,6 +1,8 @@
|
|||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||||
|
|
||||||
|
|||||||
+19
-22
@@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
@@ -185,21 +185,18 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
|
|||||||
{
|
{
|
||||||
await using var ctx = CreateContext();
|
await using var ctx = CreateContext();
|
||||||
var repo = new AuditLogRepository(ctx);
|
var repo = new AuditLogRepository(ctx);
|
||||||
var submitEvt = new AuditEvent
|
var submitEvt = ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: Guid.NewGuid(),
|
||||||
EventId = Guid.NewGuid(),
|
occurredAtUtc: DateTime.UtcNow.AddMinutes(-1),
|
||||||
OccurredAtUtc = DateTime.UtcNow.AddMinutes(-1),
|
channel: AuditChannel.Notification,
|
||||||
Channel = AuditChannel.Notification,
|
kind: AuditKind.NotifySend,
|
||||||
Kind = AuditKind.NotifySend,
|
correlationId: notificationId,
|
||||||
CorrelationId = notificationId,
|
sourceSiteId: siteId,
|
||||||
SourceSiteId = siteId,
|
sourceInstanceId: "Plant.Pump42",
|
||||||
SourceInstanceId = "Plant.Pump42",
|
sourceScript: "AlarmScript",
|
||||||
SourceScript = "AlarmScript",
|
target: "ops-team",
|
||||||
Target = "ops-team",
|
status: AuditStatus.Submitted,
|
||||||
Status = AuditStatus.Submitted,
|
ingestedAtUtc: new DateTimeOffset(DateTime.UtcNow.AddMinutes(-1)));
|
||||||
ForwardState = AuditForwardState.Forwarded,
|
|
||||||
IngestedAtUtc = DateTime.UtcNow.AddMinutes(-1),
|
|
||||||
};
|
|
||||||
await repo.InsertIfNotExistsAsync(submitEvt);
|
await repo.InsertIfNotExistsAsync(submitEvt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,9 +245,9 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
|
|||||||
new AuditLogPaging(PageSize: 50));
|
new AuditLogPaging(PageSize: 50));
|
||||||
// 1 Submit + 1 Attempted = 2 rows so far.
|
// 1 Submit + 1 Attempted = 2 rows so far.
|
||||||
Assert.Equal(2, rows.Count);
|
Assert.Equal(2, rows.Count);
|
||||||
Assert.Single(rows, r => r.Kind == AuditKind.NotifyDeliver
|
Assert.Single(rows, r => r.AsRow().Kind == AuditKind.NotifyDeliver
|
||||||
&& r.Status == AuditStatus.Attempted);
|
&& r.AsRow().Status == AuditStatus.Attempted);
|
||||||
Assert.Single(rows, r => r.Kind == AuditKind.NotifySend);
|
Assert.Single(rows, r => r.AsRow().Kind == AuditKind.NotifySend);
|
||||||
}, TimeSpan.FromSeconds(15));
|
}, TimeSpan.FromSeconds(15));
|
||||||
|
|
||||||
// Second tick: success → second Attempted + one Delivered terminal.
|
// Second tick: success → second Attempted + one Delivered terminal.
|
||||||
@@ -265,10 +262,10 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
|
|||||||
// 1 Submit + 2 Attempted + 1 Delivered terminal = 4 rows.
|
// 1 Submit + 2 Attempted + 1 Delivered terminal = 4 rows.
|
||||||
Assert.InRange(rows.Count, 3, 4);
|
Assert.InRange(rows.Count, 3, 4);
|
||||||
var notifyDeliverRows = rows
|
var notifyDeliverRows = rows
|
||||||
.Where(r => r.Kind == AuditKind.NotifyDeliver)
|
.Where(r => r.AsRow().Kind == AuditKind.NotifyDeliver)
|
||||||
.ToList();
|
.ToList();
|
||||||
Assert.Equal(2, notifyDeliverRows.Count(r => r.Status == AuditStatus.Attempted));
|
Assert.Equal(2, notifyDeliverRows.Count(r => r.AsRow().Status == AuditStatus.Attempted));
|
||||||
var terminal = Assert.Single(notifyDeliverRows, r => r.Status == AuditStatus.Delivered);
|
var terminal = Assert.Single(notifyDeliverRows, r => r.AsRow().Status == AuditStatus.Delivered);
|
||||||
// All NotifyDeliver rows correlate to the original notification id.
|
// All NotifyDeliver rows correlate to the original notification id.
|
||||||
Assert.All(notifyDeliverRows, r => Assert.Equal(notificationId, r.CorrelationId));
|
Assert.All(notifyDeliverRows, r => Assert.Equal(notificationId, r.CorrelationId));
|
||||||
Assert.Equal("ops-team", terminal.Target);
|
Assert.Equal("ops-team", terminal.Target);
|
||||||
|
|||||||
+15
-15
@@ -7,7 +7,9 @@ using Microsoft.Extensions.Options;
|
|||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||||
@@ -129,16 +131,14 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
|
|||||||
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||||
.UseSqlServer(_fixture.ConnectionString).Options);
|
.UseSqlServer(_fixture.ConnectionString).Options);
|
||||||
|
|
||||||
private static AuditEvent NewEvent(string siteId, DateTime occurredAt) => new()
|
private static AuditEvent NewEvent(string siteId, DateTime occurredAt) => ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: Guid.NewGuid(),
|
||||||
EventId = Guid.NewGuid(),
|
occurredAtUtc: occurredAt,
|
||||||
OccurredAtUtc = occurredAt,
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.ApiCall,
|
||||||
Kind = AuditKind.ApiCall,
|
status: AuditStatus.Delivered,
|
||||||
Status = AuditStatus.Delivered,
|
sourceSiteId: siteId,
|
||||||
SourceSiteId = siteId,
|
target: "external-system-a/method");
|
||||||
Target = "external-system-a/method",
|
|
||||||
};
|
|
||||||
|
|
||||||
private SqliteAuditWriter CreateInMemorySqliteWriter() =>
|
private SqliteAuditWriter CreateInMemorySqliteWriter() =>
|
||||||
new SqliteAuditWriter(
|
new SqliteAuditWriter(
|
||||||
@@ -243,7 +243,7 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
|
|||||||
await AwaitAssertAsync(async () =>
|
await AwaitAssertAsync(async () =>
|
||||||
{
|
{
|
||||||
await using var ctx = CreateContext();
|
await using var ctx = CreateContext();
|
||||||
var count = await ctx.Set<AuditEvent>()
|
var count = await ctx.Set<AuditLogRow>()
|
||||||
.Where(e => e.SourceSiteId == siteId)
|
.Where(e => e.SourceSiteId == siteId)
|
||||||
.CountAsync();
|
.CountAsync();
|
||||||
Assert.Equal(totalEvents, count);
|
Assert.Equal(totalEvents, count);
|
||||||
@@ -265,7 +265,7 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
|
|||||||
// Step 5: assert no duplicates by EventId — central must have
|
// Step 5: assert no duplicates by EventId — central must have
|
||||||
// exactly the 200 rows we wrote at the site (one row per EventId).
|
// exactly the 200 rows we wrote at the site (one row per EventId).
|
||||||
await using var verify = CreateContext();
|
await using var verify = CreateContext();
|
||||||
var centralIds = await verify.Set<AuditEvent>()
|
var centralIds = await verify.Set<AuditLogRow>()
|
||||||
.Where(e => e.SourceSiteId == siteId)
|
.Where(e => e.SourceSiteId == siteId)
|
||||||
.Select(e => e.EventId)
|
.Select(e => e.EventId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -317,7 +317,7 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
|
|||||||
await AwaitAssertAsync(async () =>
|
await AwaitAssertAsync(async () =>
|
||||||
{
|
{
|
||||||
await using var ctx = CreateContext();
|
await using var ctx = CreateContext();
|
||||||
var count = await ctx.Set<AuditEvent>()
|
var count = await ctx.Set<AuditLogRow>()
|
||||||
.Where(e => e.SourceSiteId == siteId)
|
.Where(e => e.SourceSiteId == siteId)
|
||||||
.CountAsync();
|
.CountAsync();
|
||||||
Assert.Equal(totalEvents, count);
|
Assert.Equal(totalEvents, count);
|
||||||
@@ -339,7 +339,7 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
|
|||||||
// even though the cursor + read-Reconciled-too semantics could
|
// even though the cursor + read-Reconciled-too semantics could
|
||||||
// theoretically re-fetch on the second cycle.
|
// theoretically re-fetch on the second cycle.
|
||||||
await using var verify = CreateContext();
|
await using var verify = CreateContext();
|
||||||
var rows = await verify.Set<AuditEvent>()
|
var rows = await verify.Set<AuditLogRow>()
|
||||||
.Where(e => e.SourceSiteId == siteId)
|
.Where(e => e.SourceSiteId == siteId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
Assert.Equal(totalEvents, rows.Count);
|
Assert.Equal(totalEvents, rows.Count);
|
||||||
|
|||||||
+15
-14
@@ -17,7 +17,8 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
|||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||||
@@ -315,23 +316,23 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
// + NotifyDeliver Attempted/Delivered (2) = 7 rows for the routed run.
|
// + NotifyDeliver Attempted/Delivered (2) = 7 rows for the routed run.
|
||||||
Assert.True(siteRows.Count == 7,
|
Assert.True(siteRows.Count == 7,
|
||||||
"Expected 7 routed-run audit rows; saw: "
|
"Expected 7 routed-run audit rows; saw: "
|
||||||
+ string.Join(", ", siteRows.Select(r => $"{r.Channel}/{r.Kind}/{r.Status}")));
|
+ string.Join(", ", siteRows.Select(r => $"{r.AsRow().Channel}/{r.AsRow().Kind}/{r.AsRow().Status}")));
|
||||||
Assert.Single(siteRows, r => r.Channel == AuditChannel.ApiOutbound && r.Kind == AuditKind.ApiCall);
|
Assert.Single(siteRows, r => r.AsRow().Channel == AuditChannel.ApiOutbound && r.AsRow().Kind == AuditKind.ApiCall);
|
||||||
Assert.Single(siteRows, r => r.Kind == AuditKind.CachedSubmit);
|
Assert.Single(siteRows, r => r.AsRow().Kind == AuditKind.CachedSubmit);
|
||||||
Assert.Single(siteRows, r => r.Kind == AuditKind.CachedResolve);
|
Assert.Single(siteRows, r => r.AsRow().Kind == AuditKind.CachedResolve);
|
||||||
Assert.Single(siteRows, r => r.Kind == AuditKind.NotifySend);
|
Assert.Single(siteRows, r => r.AsRow().Kind == AuditKind.NotifySend);
|
||||||
Assert.Equal(2, siteRows.Count(r => r.Kind == AuditKind.NotifyDeliver));
|
Assert.Equal(2, siteRows.Count(r => r.AsRow().Kind == AuditKind.NotifyDeliver));
|
||||||
|
|
||||||
// CORE PROMISE: every routed-run row carries the SAME non-null
|
// CORE PROMISE: every routed-run row carries the SAME non-null
|
||||||
// ParentExecutionId — the inbound request's ExecutionId.
|
// ParentExecutionId — the inbound request's ExecutionId.
|
||||||
var parentIds = siteRows.Select(r => r.ParentExecutionId).Distinct().ToList();
|
var parentIds = siteRows.Select(r => r.AsRow().ParentExecutionId).Distinct().ToList();
|
||||||
Assert.Single(parentIds);
|
Assert.Single(parentIds);
|
||||||
Assert.NotNull(parentIds[0]);
|
Assert.NotNull(parentIds[0]);
|
||||||
var inboundExecutionId = parentIds[0]!.Value;
|
var inboundExecutionId = parentIds[0]!.Value;
|
||||||
|
|
||||||
// The routed run has its OWN distinct ExecutionId — not the parent's.
|
// The routed run has its OWN distinct ExecutionId — not the parent's.
|
||||||
var routedExecutionIds = siteRows
|
var routedExecutionIds = siteRows
|
||||||
.Select(r => r.ExecutionId)
|
.Select(r => r.AsRow().ExecutionId)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
Assert.Single(routedExecutionIds);
|
Assert.Single(routedExecutionIds);
|
||||||
@@ -345,9 +346,9 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
new AuditLogQueryFilter(ExecutionId: inboundExecutionId),
|
new AuditLogQueryFilter(ExecutionId: inboundExecutionId),
|
||||||
new AuditLogPaging(PageSize: 10));
|
new AuditLogPaging(PageSize: 10));
|
||||||
var inboundRow = Assert.Single(inboundRows,
|
var inboundRow = Assert.Single(inboundRows,
|
||||||
r => r.Channel == AuditChannel.ApiInbound && r.Kind == AuditKind.InboundRequest);
|
r => r.AsRow().Channel == AuditChannel.ApiInbound && r.AsRow().Kind == AuditKind.InboundRequest);
|
||||||
Assert.Equal(AuditStatus.Delivered, inboundRow.Status);
|
Assert.Equal(AuditStatus.Delivered, inboundRow.AsRow().Status);
|
||||||
Assert.Null(inboundRow.ParentExecutionId);
|
Assert.Null(inboundRow.AsRow().ParentExecutionId);
|
||||||
|
|
||||||
// The parentExecutionId filter pulls the routed run's complete
|
// The parentExecutionId filter pulls the routed run's complete
|
||||||
// trust-boundary footprint (all 7 routed rows, none of the inbound).
|
// trust-boundary footprint (all 7 routed rows, none of the inbound).
|
||||||
@@ -355,7 +356,7 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
new AuditLogQueryFilter(ParentExecutionId: inboundExecutionId),
|
new AuditLogQueryFilter(ParentExecutionId: inboundExecutionId),
|
||||||
new AuditLogPaging(PageSize: 100));
|
new AuditLogPaging(PageSize: 100));
|
||||||
Assert.Equal(7, byParent.Count);
|
Assert.Equal(7, byParent.Count);
|
||||||
Assert.All(byParent, r => Assert.Equal(routedExecutionId, r.ExecutionId));
|
Assert.All(byParent, r => Assert.Equal(routedExecutionId, r.AsRow().ExecutionId));
|
||||||
|
|
||||||
// GetExecutionTreeAsync returns BOTH executions in one chain —
|
// GetExecutionTreeAsync returns BOTH executions in one chain —
|
||||||
// inbound (root) and routed (child), regardless of entry point.
|
// inbound (root) and routed (child), regardless of entry point.
|
||||||
@@ -502,7 +503,7 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
var pendingCached = await sqliteWriter.ReadPendingCachedTelemetryAsync(256);
|
var pendingCached = await sqliteWriter.ReadPendingCachedTelemetryAsync(256);
|
||||||
var forwarded = await sqliteWriter.ReadForwardedAsync(256);
|
var forwarded = await sqliteWriter.ReadForwardedAsync(256);
|
||||||
var kinds = pending.Concat(pendingCached).Concat(forwarded)
|
var kinds = pending.Concat(pendingCached).Concat(forwarded)
|
||||||
.Select(r => r.Kind).ToHashSet();
|
.Select(r => r.AsRow().Kind).ToHashSet();
|
||||||
var missing = expectedKinds.Where(k => !kinds.Contains(k)).ToList();
|
var missing = expectedKinds.Where(k => !kinds.Contains(k)).ToList();
|
||||||
Assert.True(
|
Assert.True(
|
||||||
missing.Count == 0,
|
missing.Count == 0,
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||||
@@ -201,7 +203,7 @@ WHERE name = 'UX_AuditLog_EventId'
|
|||||||
await Task.Delay(TimeSpan.FromMilliseconds(500));
|
await Task.Delay(TimeSpan.FromMilliseconds(500));
|
||||||
|
|
||||||
await using var verify = CreateContext();
|
await using var verify = CreateContext();
|
||||||
var rows = await verify.Set<AuditEvent>()
|
var rows = await verify.Set<AuditLogRow>()
|
||||||
.Where(e => e.SourceSiteId == siteId)
|
.Where(e => e.SourceSiteId == siteId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
@@ -325,16 +327,14 @@ WHERE name = 'UX_AuditLog_EventId'
|
|||||||
var freshEventId = Guid.NewGuid();
|
var freshEventId = Guid.NewGuid();
|
||||||
var freshOccurred = new DateTime(2026, 5, 15, 12, 0, 0, DateTimeKind.Utc);
|
var freshOccurred = new DateTime(2026, 5, 15, 12, 0, 0, DateTimeKind.Utc);
|
||||||
var freshSite = "purge-idem-fresh-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
var freshSite = "purge-idem-fresh-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||||
var freshEvt = new AuditEvent
|
var freshEvt = ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: freshEventId,
|
||||||
EventId = freshEventId,
|
occurredAtUtc: freshOccurred,
|
||||||
OccurredAtUtc = freshOccurred,
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.ApiCall,
|
||||||
Kind = AuditKind.ApiCall,
|
status: AuditStatus.Delivered,
|
||||||
Status = AuditStatus.Delivered,
|
sourceSiteId: freshSite,
|
||||||
SourceSiteId = freshSite,
|
target: "system-x/method");
|
||||||
Target = "system-x/method",
|
|
||||||
};
|
|
||||||
|
|
||||||
await using (var ctx = CreateContext())
|
await using (var ctx = CreateContext())
|
||||||
{
|
{
|
||||||
@@ -345,7 +345,7 @@ WHERE name = 'UX_AuditLog_EventId'
|
|||||||
}
|
}
|
||||||
|
|
||||||
await using var verify = CreateContext();
|
await using var verify = CreateContext();
|
||||||
var rows = await verify.Set<AuditEvent>()
|
var rows = await verify.Set<AuditLogRow>()
|
||||||
.Where(e => e.SourceSiteId == freshSite)
|
.Where(e => e.SourceSiteId == freshSite)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
Assert.Single(rows);
|
Assert.Single(rows);
|
||||||
|
|||||||
+10
-12
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
|||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration.Infrastructure;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
@@ -67,16 +67,14 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
|
|||||||
return new ScadaBridgeDbContext(options);
|
return new ScadaBridgeDbContext(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AuditEvent NewEvent(string siteId, Guid? id = null) => new()
|
private static AuditEvent NewEvent(string siteId, Guid? id = null) => ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: id ?? Guid.NewGuid(),
|
||||||
EventId = id ?? Guid.NewGuid(),
|
occurredAtUtc: DateTime.UtcNow,
|
||||||
OccurredAtUtc = DateTime.UtcNow,
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.ApiCall,
|
||||||
Kind = AuditKind.ApiCall,
|
status: AuditStatus.Delivered,
|
||||||
Status = AuditStatus.Delivered,
|
sourceSiteId: siteId,
|
||||||
SourceSiteId = siteId,
|
target: "external-system-a/method");
|
||||||
Target = "external-system-a/method",
|
|
||||||
};
|
|
||||||
|
|
||||||
private static IOptions<SqliteAuditWriterOptions> InMemorySqliteOptions() =>
|
private static IOptions<SqliteAuditWriterOptions> InMemorySqliteOptions() =>
|
||||||
Options.Create(new SqliteAuditWriterOptions
|
Options.Create(new SqliteAuditWriterOptions
|
||||||
@@ -167,7 +165,7 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
|
|||||||
Assert.Single(rows);
|
Assert.Single(rows);
|
||||||
Assert.Equal(evt.EventId, rows[0].EventId);
|
Assert.Equal(evt.EventId, rows[0].EventId);
|
||||||
// Central stamps IngestedAtUtc; site never sets it.
|
// Central stamps IngestedAtUtc; site never sets it.
|
||||||
Assert.NotNull(rows[0].IngestedAtUtc);
|
Assert.NotNull(rows[0].AsRow().IngestedAtUtc);
|
||||||
}, TimeSpan.FromSeconds(15));
|
}, TimeSpan.FromSeconds(15));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,207 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Bundle B (M5-T4) tests for body regex redaction in
|
|
||||||
/// <see cref="DefaultAuditPayloadFilter"/>. The body-redactor stage runs
|
|
||||||
/// regex replace against RequestSummary / ResponseSummary / ErrorDetail /
|
|
||||||
/// Extra, replacing every match with <c><redacted></c>. Regexes come
|
|
||||||
/// from <see cref="AuditLogOptions.GlobalBodyRedactors"/> plus the per-target
|
|
||||||
/// <see cref="PerTargetRedactionOverride.AdditionalBodyRedactors"/>. Each
|
|
||||||
/// regex is compiled with a 50 ms timeout so catastrophic-backtracking
|
|
||||||
/// patterns trip a <see cref="System.Text.RegularExpressions.RegexMatchTimeoutException"/>;
|
|
||||||
/// when that happens the offending field is over-redacted with
|
|
||||||
/// <c><redacted: redactor error></c> and the
|
|
||||||
/// <see cref="IAuditRedactionFailureCounter"/> is incremented. The stage runs
|
|
||||||
/// BEFORE truncation.
|
|
||||||
/// </summary>
|
|
||||||
public class BodyRegexRedactionTests
|
|
||||||
{
|
|
||||||
private static IOptionsMonitor<AuditLogOptions> Monitor(AuditLogOptions? opts = null) =>
|
|
||||||
new StaticMonitor(opts ?? new AuditLogOptions());
|
|
||||||
|
|
||||||
private static DefaultAuditPayloadFilter Filter(
|
|
||||||
AuditLogOptions? opts = null,
|
|
||||||
IAuditRedactionFailureCounter? counter = null) =>
|
|
||||||
new(Monitor(opts), NullLogger<DefaultAuditPayloadFilter>.Instance, counter);
|
|
||||||
|
|
||||||
private static AuditEvent NewEvent(
|
|
||||||
AuditStatus status = AuditStatus.Delivered,
|
|
||||||
string? request = null,
|
|
||||||
string? response = null,
|
|
||||||
string? errorDetail = null,
|
|
||||||
string? extra = null,
|
|
||||||
string? target = null) => new()
|
|
||||||
{
|
|
||||||
EventId = Guid.NewGuid(),
|
|
||||||
OccurredAtUtc = DateTime.UtcNow,
|
|
||||||
Channel = AuditChannel.ApiOutbound,
|
|
||||||
Kind = AuditKind.ApiCall,
|
|
||||||
Status = status,
|
|
||||||
Target = target,
|
|
||||||
RequestSummary = request,
|
|
||||||
ResponseSummary = response,
|
|
||||||
ErrorDetail = errorDetail,
|
|
||||||
Extra = extra,
|
|
||||||
};
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GlobalRegex_HunterPassword_Redacted()
|
|
||||||
{
|
|
||||||
var opts = new AuditLogOptions
|
|
||||||
{
|
|
||||||
GlobalBodyRedactors = new List<string> { "\"password\":\\s*\"[^\"]*\"" },
|
|
||||||
};
|
|
||||||
const string input = "{\"user\":\"alice\",\"password\":\"hunter2\"}";
|
|
||||||
var evt = NewEvent(request: input);
|
|
||||||
|
|
||||||
var result = Filter(opts).Apply(evt);
|
|
||||||
|
|
||||||
Assert.NotNull(result.RequestSummary);
|
|
||||||
Assert.Contains("<redacted>", result.RequestSummary);
|
|
||||||
Assert.DoesNotContain("hunter2", result.RequestSummary);
|
|
||||||
Assert.Contains("alice", result.RequestSummary);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void PerTargetRegex_OnlyAppliedToMatchingTarget()
|
|
||||||
{
|
|
||||||
var opts = new AuditLogOptions
|
|
||||||
{
|
|
||||||
PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
|
|
||||||
{
|
|
||||||
["esg.A"] = new PerTargetRedactionOverride
|
|
||||||
{
|
|
||||||
AdditionalBodyRedactors = new List<string> { "SECRET-[A-Z0-9]+" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const string input = "token=SECRET-XYZ123 normal-text";
|
|
||||||
|
|
||||||
var matchedEvt = NewEvent(request: input, target: "esg.A");
|
|
||||||
var matchedResult = Filter(opts).Apply(matchedEvt);
|
|
||||||
Assert.Contains("<redacted>", matchedResult.RequestSummary!);
|
|
||||||
Assert.DoesNotContain("SECRET-XYZ123", matchedResult.RequestSummary!);
|
|
||||||
|
|
||||||
var unmatchedEvt = NewEvent(request: input, target: "esg.B");
|
|
||||||
var unmatchedResult = Filter(opts).Apply(unmatchedEvt);
|
|
||||||
Assert.Equal(input, unmatchedResult.RequestSummary);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RegexThrowsTimeout_FieldBecomesRedactedMarker_CounterIncrements()
|
|
||||||
{
|
|
||||||
// Catastrophic backtracking pattern: alternation with overlapping
|
|
||||||
// groups + non-matching suffix forces the engine into exponential
|
|
||||||
// work that blows past the 50 ms timeout. Append a non-'a' character
|
|
||||||
// so the suffix anchor fails and the engine has to exhaust every
|
|
||||||
// permutation.
|
|
||||||
var opts = new AuditLogOptions
|
|
||||||
{
|
|
||||||
GlobalBodyRedactors = new List<string> { "^(a+)+$" },
|
|
||||||
};
|
|
||||||
// 30 'a's followed by '!' — small enough to keep the test fast, big
|
|
||||||
// enough to overflow the 50 ms regex timeout on every machine the CI
|
|
||||||
// grid runs on.
|
|
||||||
var input = new string('a', 30) + "!";
|
|
||||||
var counter = new CountingRedactionFailureCounter();
|
|
||||||
var evt = NewEvent(request: input);
|
|
||||||
|
|
||||||
var result = Filter(opts, counter).Apply(evt);
|
|
||||||
|
|
||||||
Assert.Equal("<redacted: redactor error>", result.RequestSummary);
|
|
||||||
Assert.True(counter.Count >= 1, $"expected counter >= 1, got {counter.Count}");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NoRegexConfigured_FieldUnchanged()
|
|
||||||
{
|
|
||||||
var opts = new AuditLogOptions(); // no GlobalBodyRedactors, no per-target
|
|
||||||
const string input = "{\"password\":\"hunter2\"}";
|
|
||||||
var evt = NewEvent(request: input);
|
|
||||||
|
|
||||||
var result = Filter(opts).Apply(evt);
|
|
||||||
|
|
||||||
Assert.Equal(input, result.RequestSummary);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RedactionAppliedBeforeTruncation()
|
|
||||||
{
|
|
||||||
// A pattern that matches a long secret in the body. The full input is
|
|
||||||
// > 8 KB so truncation must run. After redaction:
|
|
||||||
// * the marker survives the cap (redaction ran first),
|
|
||||||
// * the original secret bytes do NOT survive,
|
|
||||||
// * PayloadTruncated is set.
|
|
||||||
var opts = new AuditLogOptions
|
|
||||||
{
|
|
||||||
GlobalBodyRedactors = new List<string> { "SECRET-[A-Z0-9]+" },
|
|
||||||
};
|
|
||||||
var secret = "SECRET-ABCDEF123";
|
|
||||||
var padding = new string('x', 9 * 1024);
|
|
||||||
var input = secret + padding;
|
|
||||||
Assert.True(Encoding.UTF8.GetByteCount(input) > 8192);
|
|
||||||
|
|
||||||
var evt = NewEvent(AuditStatus.Delivered, request: input);
|
|
||||||
|
|
||||||
var result = Filter(opts).Apply(evt);
|
|
||||||
|
|
||||||
Assert.NotNull(result.RequestSummary);
|
|
||||||
Assert.True(Encoding.UTF8.GetByteCount(result.RequestSummary!) <= 8192);
|
|
||||||
Assert.Contains("<redacted>", result.RequestSummary);
|
|
||||||
Assert.DoesNotContain(secret, result.RequestSummary);
|
|
||||||
Assert.True(result.PayloadTruncated);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CatastrophicBacktrackingRegex_AtCompileTime_RejectedAtStartup()
|
|
||||||
{
|
|
||||||
// .NET's regex engine has no compile-time detection for catastrophic
|
|
||||||
// backtracking (only structural validation), so the filter's
|
|
||||||
// protection is RUNTIME — the 50 ms per-match timeout. We assert the
|
|
||||||
// safety net behaviour: a known evil pattern compiles cleanly but
|
|
||||||
// matches time out at runtime, the field is over-redacted, and the
|
|
||||||
// failure counter is incremented. Future engines that DO support
|
|
||||||
// compile-time analysis can tighten this further; the contract here
|
|
||||||
// is that the user-facing action is never aborted.
|
|
||||||
var evilPattern = "^(a+)+$";
|
|
||||||
var opts = new AuditLogOptions
|
|
||||||
{
|
|
||||||
GlobalBodyRedactors = new List<string> { evilPattern },
|
|
||||||
};
|
|
||||||
var input = new string('a', 30) + "!";
|
|
||||||
var counter = new CountingRedactionFailureCounter();
|
|
||||||
var evt = NewEvent(request: input);
|
|
||||||
|
|
||||||
var result = Filter(opts, counter).Apply(evt);
|
|
||||||
|
|
||||||
Assert.Equal("<redacted: redactor error>", result.RequestSummary);
|
|
||||||
Assert.True(counter.Count >= 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Test double that counts increments.</summary>
|
|
||||||
private sealed class CountingRedactionFailureCounter : IAuditRedactionFailureCounter
|
|
||||||
{
|
|
||||||
private int _count;
|
|
||||||
public int Count => _count;
|
|
||||||
public void Increment() => System.Threading.Interlocked.Increment(ref _count);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>IOptionsMonitor test double — returns the same snapshot on every read.</summary>
|
|
||||||
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
|
|
||||||
{
|
|
||||||
private readonly AuditLogOptions _value;
|
|
||||||
public StaticMonitor(AuditLogOptions value) => _value = value;
|
|
||||||
public AuditLogOptions CurrentValue => _value;
|
|
||||||
public AuditLogOptions Get(string? name) => _value;
|
|
||||||
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
using Akka.Actor;
|
|
||||||
using Akka.TestKit.Xunit2;
|
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using NSubstitute;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Bundle C (M5-T6) integration tests verifying that the
|
|
||||||
/// <see cref="IAuditPayloadFilter"/> wires correctly into each of the three
|
|
||||||
/// writer entry points — <see cref="FallbackAuditWriter"/> on the site hot
|
|
||||||
/// path, <see cref="CentralAuditWriter"/> on the central direct-write path,
|
|
||||||
/// and <see cref="AuditLogIngestActor"/> on the site→central telemetry ingest
|
|
||||||
/// path (both the per-row <c>IngestAuditEventsCommand</c> handler and the
|
|
||||||
/// combined <c>IngestCachedTelemetryCommand</c> dual-write handler).
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Bundle B established the filter's behaviour in isolation (truncation,
|
|
||||||
/// header redaction, body-regex redaction, SQL-parameter redaction). Bundle C
|
|
||||||
/// proves that filtering actually happens before persistence — a 10 KB
|
|
||||||
/// RequestSummary on a Delivered row must land on disk capped to 8192 bytes
|
|
||||||
/// with <c>PayloadTruncated=true</c>, regardless of whether the row was
|
|
||||||
/// written via the site's SQLite hot path, the central direct-write path, or
|
|
||||||
/// the site→central ingest pipeline. We use the production
|
|
||||||
/// <see cref="DefaultAuditPayloadFilter"/> through every test so the
|
|
||||||
/// integration is real end-to-end, not a fake-filter assertion.
|
|
||||||
/// </remarks>
|
|
||||||
public class FilterIntegrationTests
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Default-options filter — 8 KiB cap on success rows, 64 KiB on error
|
|
||||||
/// rows. Cached and reused; the filter is stateless w.r.t. the per-event
|
|
||||||
/// inputs and the regex cache is happy under sharing.
|
|
||||||
/// </summary>
|
|
||||||
private static IAuditPayloadFilter NewDefaultFilter()
|
|
||||||
{
|
|
||||||
var monitor = Microsoft.Extensions.Options.Options.Create(new AuditLogOptions());
|
|
||||||
return new DefaultAuditPayloadFilter(
|
|
||||||
new StaticMonitor(monitor.Value),
|
|
||||||
NullLogger<DefaultAuditPayloadFilter>.Instance);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static AuditEvent NewEvent(string? request = null, Guid? eventId = null) => new()
|
|
||||||
{
|
|
||||||
EventId = eventId ?? Guid.NewGuid(),
|
|
||||||
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
|
||||||
Channel = AuditChannel.ApiOutbound,
|
|
||||||
Kind = AuditKind.ApiCall,
|
|
||||||
// Delivered = success cap (8 KiB). Picking a success status so the
|
|
||||||
// 10 KB payload reliably trips the filter.
|
|
||||||
Status = AuditStatus.Delivered,
|
|
||||||
RequestSummary = request,
|
|
||||||
PayloadTruncated = false,
|
|
||||||
ForwardState = AuditForwardState.Pending,
|
|
||||||
};
|
|
||||||
|
|
||||||
// -- C1.1: FallbackAuditWriter applies the filter before SQLite write ----
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task FallbackAuditWriter_AppliesFilter_BeforeSqliteWrite()
|
|
||||||
{
|
|
||||||
var dataSource =
|
|
||||||
$"file:filter-fbw-{Guid.NewGuid():N}?mode=memory&cache=shared";
|
|
||||||
// Hold the in-memory database alive for the verifier connection —
|
|
||||||
// SQLite frees a Cache=Shared in-memory DB when the last connection
|
|
||||||
// closes, so without this keep-alive the FallbackAuditWriter's
|
|
||||||
// dispose would wipe the data before we could query it.
|
|
||||||
using var keepAlive = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
|
|
||||||
keepAlive.Open();
|
|
||||||
|
|
||||||
var sqliteWriter = new SqliteAuditWriter(
|
|
||||||
Microsoft.Extensions.Options.Options.Create(new SqliteAuditWriterOptions { DatabasePath = dataSource }),
|
|
||||||
NullLogger<SqliteAuditWriter>.Instance,
|
|
||||||
new FakeNodeIdentityProvider(),
|
|
||||||
connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
|
|
||||||
await using var _disposeSqlite = sqliteWriter;
|
|
||||||
|
|
||||||
var fallback = new FallbackAuditWriter(
|
|
||||||
sqliteWriter,
|
|
||||||
new RingBufferFallback(),
|
|
||||||
new NoOpAuditWriteFailureCounter(),
|
|
||||||
NullLogger<FallbackAuditWriter>.Instance,
|
|
||||||
NewDefaultFilter());
|
|
||||||
|
|
||||||
var bigRequest = new string('a', 10 * 1024);
|
|
||||||
var evt = NewEvent(request: bigRequest);
|
|
||||||
await fallback.WriteAsync(evt);
|
|
||||||
|
|
||||||
// Read back via a fresh connection so we observe what actually
|
|
||||||
// landed in SQLite — not what the writer was handed.
|
|
||||||
using var verifier = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
|
|
||||||
verifier.Open();
|
|
||||||
using var cmd = verifier.CreateCommand();
|
|
||||||
cmd.CommandText = "SELECT RequestSummary, PayloadTruncated FROM AuditLog WHERE EventId = $id;";
|
|
||||||
cmd.Parameters.AddWithValue("$id", evt.EventId.ToString());
|
|
||||||
using var reader = cmd.ExecuteReader();
|
|
||||||
Assert.True(reader.Read());
|
|
||||||
var persistedRequest = reader.GetString(0);
|
|
||||||
var truncatedFlag = reader.GetInt32(1);
|
|
||||||
|
|
||||||
Assert.Equal(8192, Encoding.UTF8.GetByteCount(persistedRequest));
|
|
||||||
Assert.Equal(1, truncatedFlag);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- C1.2: CentralAuditWriter applies the filter before repo insert ------
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CentralAuditWriter_AppliesFilter_BeforeRepoInsert()
|
|
||||||
{
|
|
||||||
var repo = Substitute.For<IAuditLogRepository>();
|
|
||||||
var services = new ServiceCollection();
|
|
||||||
services.AddScoped(_ => repo);
|
|
||||||
services.AddSingleton(NewDefaultFilter());
|
|
||||||
var provider = services.BuildServiceProvider();
|
|
||||||
|
|
||||||
var writer = new CentralAuditWriter(
|
|
||||||
provider, NullLogger<CentralAuditWriter>.Instance, NewDefaultFilter());
|
|
||||||
|
|
||||||
var bigRequest = new string('b', 10 * 1024);
|
|
||||||
var evt = NewEvent(request: bigRequest);
|
|
||||||
await writer.WriteAsync(evt);
|
|
||||||
|
|
||||||
// Verify the repository saw the FILTERED event, not the raw one.
|
|
||||||
// The filter caps RequestSummary to 8192 bytes on a Delivered row
|
|
||||||
// and flags PayloadTruncated.
|
|
||||||
await repo.Received(1).InsertIfNotExistsAsync(
|
|
||||||
Arg.Is<AuditEvent>(e =>
|
|
||||||
e.EventId == evt.EventId
|
|
||||||
&& e.RequestSummary != null
|
|
||||||
&& Encoding.UTF8.GetByteCount(e.RequestSummary) == 8192
|
|
||||||
&& e.PayloadTruncated == true),
|
|
||||||
Arg.Any<CancellationToken>());
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- C1.3 + C1.4: AuditLogIngestActor applies the filter on both paths ---
|
|
||||||
|
|
||||||
public class IngestActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
|
||||||
{
|
|
||||||
private readonly MsSqlMigrationFixture _fixture;
|
|
||||||
|
|
||||||
public IngestActorTests(MsSqlMigrationFixture fixture)
|
|
||||||
{
|
|
||||||
_fixture = fixture;
|
|
||||||
}
|
|
||||||
|
|
||||||
private ScadaBridgeDbContext CreateReadContext()
|
|
||||||
{
|
|
||||||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
|
||||||
.UseSqlServer(_fixture.ConnectionString)
|
|
||||||
.Options;
|
|
||||||
return new ScadaBridgeDbContext(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NewSiteId() =>
|
|
||||||
"test-bundle-c1-filter-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Build the IServiceProvider in the production-flavoured shape —
|
|
||||||
/// scoped repositories + a singleton <see cref="IAuditPayloadFilter"/>
|
|
||||||
/// resolved per-message from the actor's scope. Matches the
|
|
||||||
/// AddAuditLog registrations Bundle B established.
|
|
||||||
/// </summary>
|
|
||||||
private IServiceProvider BuildServiceProvider()
|
|
||||||
{
|
|
||||||
var services = new ServiceCollection();
|
|
||||||
services.AddDbContext<ScadaBridgeDbContext>(opts =>
|
|
||||||
opts.UseSqlServer(_fixture.ConnectionString)
|
|
||||||
.ConfigureWarnings(w => w.Ignore(
|
|
||||||
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
|
||||||
services.AddScoped<IAuditLogRepository>(sp =>
|
|
||||||
new AuditLogRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
|
|
||||||
services.AddScoped<ISiteCallAuditRepository>(sp =>
|
|
||||||
new SiteCallAuditRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
|
|
||||||
services.AddSingleton(NewDefaultFilter());
|
|
||||||
return services.BuildServiceProvider();
|
|
||||||
}
|
|
||||||
|
|
||||||
[SkippableFact]
|
|
||||||
public async Task AuditLogIngestActor_AppliesFilter_BeforeBatchInsert()
|
|
||||||
{
|
|
||||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
|
||||||
|
|
||||||
var siteId = NewSiteId();
|
|
||||||
var bigRequest = new string('c', 10 * 1024);
|
|
||||||
var evt = new AuditEvent
|
|
||||||
{
|
|
||||||
EventId = Guid.NewGuid(),
|
|
||||||
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
|
||||||
Channel = AuditChannel.ApiOutbound,
|
|
||||||
Kind = AuditKind.ApiCall,
|
|
||||||
Status = AuditStatus.Delivered,
|
|
||||||
SourceSiteId = siteId,
|
|
||||||
RequestSummary = bigRequest,
|
|
||||||
PayloadTruncated = false,
|
|
||||||
};
|
|
||||||
|
|
||||||
var sp = BuildServiceProvider();
|
|
||||||
var actor = Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
|
|
||||||
sp, NullLogger<AuditLogIngestActor>.Instance)));
|
|
||||||
|
|
||||||
actor.Tell(new IngestAuditEventsCommand(new[] { evt }), TestActor);
|
|
||||||
ExpectMsg<IngestAuditEventsReply>(TimeSpan.FromSeconds(15));
|
|
||||||
|
|
||||||
// Verify the persisted row was filtered before INSERT.
|
|
||||||
await using var read = CreateReadContext();
|
|
||||||
var row = await read.Set<AuditEvent>()
|
|
||||||
.SingleAsync(e => e.EventId == evt.EventId);
|
|
||||||
Assert.NotNull(row.RequestSummary);
|
|
||||||
Assert.Equal(8192, Encoding.UTF8.GetByteCount(row.RequestSummary!));
|
|
||||||
Assert.True(row.PayloadTruncated);
|
|
||||||
}
|
|
||||||
|
|
||||||
[SkippableFact]
|
|
||||||
public async Task AuditLogIngestActor_CachedTelemetry_AppliesFilter()
|
|
||||||
{
|
|
||||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
|
||||||
|
|
||||||
var siteId = NewSiteId();
|
|
||||||
var trackedId = TrackedOperationId.New();
|
|
||||||
var bigRequest = new string('d', 10 * 1024);
|
|
||||||
var audit = new AuditEvent
|
|
||||||
{
|
|
||||||
EventId = Guid.NewGuid(),
|
|
||||||
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
|
||||||
Channel = AuditChannel.ApiOutbound,
|
|
||||||
Kind = AuditKind.CachedSubmit,
|
|
||||||
Status = AuditStatus.Submitted,
|
|
||||||
SourceSiteId = siteId,
|
|
||||||
CorrelationId = trackedId.Value,
|
|
||||||
RequestSummary = bigRequest,
|
|
||||||
PayloadTruncated = false,
|
|
||||||
};
|
|
||||||
var siteCall = new SiteCall
|
|
||||||
{
|
|
||||||
TrackedOperationId = trackedId,
|
|
||||||
Channel = "ApiOutbound",
|
|
||||||
Target = "ERP.GetOrder",
|
|
||||||
SourceSite = siteId,
|
|
||||||
Status = "Submitted",
|
|
||||||
RetryCount = 0,
|
|
||||||
CreatedAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
|
||||||
UpdatedAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
|
||||||
IngestedAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
|
||||||
};
|
|
||||||
|
|
||||||
var sp = BuildServiceProvider();
|
|
||||||
var actor = Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
|
|
||||||
sp, NullLogger<AuditLogIngestActor>.Instance)));
|
|
||||||
|
|
||||||
actor.Tell(
|
|
||||||
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(audit, siteCall) }),
|
|
||||||
TestActor);
|
|
||||||
ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
|
|
||||||
|
|
||||||
await using var read = CreateReadContext();
|
|
||||||
var auditRow = await read.Set<AuditEvent>()
|
|
||||||
.SingleAsync(e => e.EventId == audit.EventId);
|
|
||||||
Assert.NotNull(auditRow.RequestSummary);
|
|
||||||
// Bundle C filter must run before the dual-write transaction
|
|
||||||
// commits, so the persisted AuditLog row carries the truncated
|
|
||||||
// payload.
|
|
||||||
Assert.Equal(8192, Encoding.UTF8.GetByteCount(auditRow.RequestSummary!));
|
|
||||||
Assert.True(auditRow.PayloadTruncated);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// IOptionsMonitor test double — returns the same snapshot on every read,
|
|
||||||
/// no change-token plumbing required for these tests. Mirrors the helper
|
|
||||||
/// used in <c>TruncationTests</c>.
|
|
||||||
/// </summary>
|
|
||||||
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
|
|
||||||
{
|
|
||||||
private readonly AuditLogOptions _value;
|
|
||||||
|
|
||||||
public StaticMonitor(AuditLogOptions value) => _value = value;
|
|
||||||
|
|
||||||
public AuditLogOptions CurrentValue => _value;
|
|
||||||
|
|
||||||
public AuditLogOptions Get(string? name) => _value;
|
|
||||||
|
|
||||||
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Bundle B (M5-T3) tests for <see cref="DefaultAuditPayloadFilter"/> HTTP header
|
|
||||||
/// redaction. Redaction parses <see cref="AuditEvent.RequestSummary"/> /
|
|
||||||
/// <see cref="AuditEvent.ResponseSummary"/> as JSON of shape
|
|
||||||
/// <c>{"headers": {"name": "value", ...}, "body": "..."}</c>, replaces values
|
|
||||||
/// whose header NAME (case-insensitive) is in
|
|
||||||
/// <see cref="AuditLogOptions.HeaderRedactList"/> with <c>"<redacted>"</c>,
|
|
||||||
/// and re-serialises. Non-JSON inputs pass through unchanged (no-op for
|
|
||||||
/// emitters that have not yet adopted the convention). The stage runs BEFORE
|
|
||||||
/// truncation so the redaction marker survives the cap.
|
|
||||||
/// </summary>
|
|
||||||
public class HeaderRedactionTests
|
|
||||||
{
|
|
||||||
private static IOptionsMonitor<AuditLogOptions> Monitor(AuditLogOptions? opts = null) =>
|
|
||||||
new StaticMonitor(opts ?? new AuditLogOptions());
|
|
||||||
|
|
||||||
private static DefaultAuditPayloadFilter Filter(AuditLogOptions? opts = null) =>
|
|
||||||
new(Monitor(opts), NullLogger<DefaultAuditPayloadFilter>.Instance);
|
|
||||||
|
|
||||||
private static AuditEvent NewEvent(
|
|
||||||
AuditStatus status = AuditStatus.Delivered,
|
|
||||||
string? request = null,
|
|
||||||
string? response = null) => new()
|
|
||||||
{
|
|
||||||
EventId = Guid.NewGuid(),
|
|
||||||
OccurredAtUtc = DateTime.UtcNow,
|
|
||||||
Channel = AuditChannel.ApiOutbound,
|
|
||||||
Kind = AuditKind.ApiCall,
|
|
||||||
Status = status,
|
|
||||||
RequestSummary = request,
|
|
||||||
ResponseSummary = response,
|
|
||||||
};
|
|
||||||
|
|
||||||
private static string BuildSummary(IDictionary<string, string> headers, string body)
|
|
||||||
{
|
|
||||||
// Serialize via System.Text.Json so we get a representative shape.
|
|
||||||
return JsonSerializer.Serialize(new
|
|
||||||
{
|
|
||||||
headers = headers,
|
|
||||||
body = body,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IDictionary<string, JsonElement> ParseSummary(string? summary)
|
|
||||||
{
|
|
||||||
Assert.NotNull(summary);
|
|
||||||
using var doc = JsonDocument.Parse(summary!);
|
|
||||||
var dict = new Dictionary<string, JsonElement>();
|
|
||||||
foreach (var property in doc.RootElement.EnumerateObject())
|
|
||||||
{
|
|
||||||
dict[property.Name] = property.Value.Clone();
|
|
||||||
}
|
|
||||||
return dict;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void HeaderRedaction_AuthorizationBearer_Redacted()
|
|
||||||
{
|
|
||||||
var headers = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["Authorization"] = "Bearer secret-token-xyz",
|
|
||||||
["Content-Type"] = "application/json",
|
|
||||||
};
|
|
||||||
var input = BuildSummary(headers, "hello");
|
|
||||||
var evt = NewEvent(request: input);
|
|
||||||
|
|
||||||
var result = Filter().Apply(evt);
|
|
||||||
|
|
||||||
var parsed = ParseSummary(result.RequestSummary);
|
|
||||||
var resultHeaders = parsed["headers"];
|
|
||||||
Assert.Equal("<redacted>", resultHeaders.GetProperty("Authorization").GetString());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void HeaderRedaction_CaseInsensitive_LowercaseAuthorization_Redacted()
|
|
||||||
{
|
|
||||||
var headers = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["authorization"] = "Bearer secret-token-xyz",
|
|
||||||
};
|
|
||||||
var input = BuildSummary(headers, "hello");
|
|
||||||
var evt = NewEvent(request: input);
|
|
||||||
|
|
||||||
var result = Filter().Apply(evt);
|
|
||||||
|
|
||||||
var parsed = ParseSummary(result.RequestSummary);
|
|
||||||
var resultHeaders = parsed["headers"];
|
|
||||||
Assert.Equal("<redacted>", resultHeaders.GetProperty("authorization").GetString());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void HeaderRedaction_CustomRedactList_RedactsCustomHeaderName()
|
|
||||||
{
|
|
||||||
var opts = new AuditLogOptions
|
|
||||||
{
|
|
||||||
HeaderRedactList = new List<string> { "X-Custom-Secret" },
|
|
||||||
};
|
|
||||||
var headers = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["X-Custom-Secret"] = "topsecret",
|
|
||||||
["Authorization"] = "Bearer keep-me", // not in list anymore
|
|
||||||
};
|
|
||||||
var input = BuildSummary(headers, "hi");
|
|
||||||
var evt = NewEvent(request: input);
|
|
||||||
|
|
||||||
var result = Filter(opts).Apply(evt);
|
|
||||||
|
|
||||||
var parsed = ParseSummary(result.RequestSummary);
|
|
||||||
var resultHeaders = parsed["headers"];
|
|
||||||
Assert.Equal("<redacted>", resultHeaders.GetProperty("X-Custom-Secret").GetString());
|
|
||||||
// Authorization no longer listed -> preserved verbatim.
|
|
||||||
Assert.Equal("Bearer keep-me", resultHeaders.GetProperty("Authorization").GetString());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void HeaderRedaction_NonJson_RequestSummary_Unchanged()
|
|
||||||
{
|
|
||||||
const string input = "this is not JSON at all";
|
|
||||||
var evt = NewEvent(request: input);
|
|
||||||
|
|
||||||
var result = Filter().Apply(evt);
|
|
||||||
|
|
||||||
Assert.Equal(input, result.RequestSummary);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void HeaderRedaction_NoHeadersField_Unchanged()
|
|
||||||
{
|
|
||||||
var input = JsonSerializer.Serialize(new { body = "only a body, no headers" });
|
|
||||||
var evt = NewEvent(request: input);
|
|
||||||
|
|
||||||
var result = Filter().Apply(evt);
|
|
||||||
|
|
||||||
// The stage may re-serialise but the content must be semantically identical.
|
|
||||||
var parsed = ParseSummary(result.RequestSummary);
|
|
||||||
Assert.Equal("only a body, no headers", parsed["body"].GetString());
|
|
||||||
Assert.False(parsed.ContainsKey("headers"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void HeaderRedaction_Other_Headers_Preserved()
|
|
||||||
{
|
|
||||||
var headers = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["Authorization"] = "Bearer secret",
|
|
||||||
["Content-Type"] = "application/json",
|
|
||||||
["X-Request-Id"] = "abc-123",
|
|
||||||
["Accept"] = "application/json",
|
|
||||||
};
|
|
||||||
var input = BuildSummary(headers, "payload");
|
|
||||||
var evt = NewEvent(request: input);
|
|
||||||
|
|
||||||
var result = Filter().Apply(evt);
|
|
||||||
|
|
||||||
var parsed = ParseSummary(result.RequestSummary);
|
|
||||||
var resultHeaders = parsed["headers"];
|
|
||||||
Assert.Equal("<redacted>", resultHeaders.GetProperty("Authorization").GetString());
|
|
||||||
Assert.Equal("application/json", resultHeaders.GetProperty("Content-Type").GetString());
|
|
||||||
Assert.Equal("abc-123", resultHeaders.GetProperty("X-Request-Id").GetString());
|
|
||||||
Assert.Equal("application/json", resultHeaders.GetProperty("Accept").GetString());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void HeaderRedaction_AppliedBeforeTruncation()
|
|
||||||
{
|
|
||||||
// Build a summary whose Authorization header value is enormous AND whose
|
|
||||||
// body padding pushes the total beyond the 8 KB cap. After redaction the
|
|
||||||
// Authorization value becomes "<redacted>" — then truncation caps the
|
|
||||||
// re-serialised string. Result must:
|
|
||||||
// * carry "<redacted>" (header redaction ran first),
|
|
||||||
// * NOT carry the original secret bytes (proves redaction won, not order swap),
|
|
||||||
// * be capped at the configured DefaultCapBytes,
|
|
||||||
// * have PayloadTruncated == true.
|
|
||||||
const string secret = "SUPER-SECRET-TOKEN-DO-NOT-LEAK";
|
|
||||||
var headers = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["Authorization"] = "Bearer " + secret,
|
|
||||||
};
|
|
||||||
var body = new string('x', 9 * 1024);
|
|
||||||
var input = BuildSummary(headers, body);
|
|
||||||
Assert.True(Encoding.UTF8.GetByteCount(input) > 8192);
|
|
||||||
|
|
||||||
var evt = NewEvent(AuditStatus.Delivered, request: input);
|
|
||||||
|
|
||||||
var result = Filter().Apply(evt);
|
|
||||||
|
|
||||||
Assert.NotNull(result.RequestSummary);
|
|
||||||
Assert.True(Encoding.UTF8.GetByteCount(result.RequestSummary!) <= 8192);
|
|
||||||
Assert.Contains("<redacted>", result.RequestSummary);
|
|
||||||
Assert.DoesNotContain(secret, result.RequestSummary);
|
|
||||||
Assert.True(result.PayloadTruncated);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// IOptionsMonitor test double — returns the same snapshot on every read,
|
|
||||||
/// no change-token plumbing required for these tests.
|
|
||||||
/// </summary>
|
|
||||||
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
|
|
||||||
{
|
|
||||||
private readonly AuditLogOptions _value;
|
|
||||||
public StaticMonitor(AuditLogOptions value) => _value = value;
|
|
||||||
public AuditLogOptions CurrentValue => _value;
|
|
||||||
public AuditLogOptions Get(string? name) => _value;
|
|
||||||
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pins the docs/plans/2026-05-23-inbound-api-full-response-audit-design.md
|
|
||||||
/// inbound carve-out: ApiInbound rows use InboundMaxBytes (default 1 MiB) for
|
|
||||||
/// RequestSummary / ResponseSummary truncation, NOT DefaultCapBytes /
|
|
||||||
/// ErrorCapBytes. Other channels keep the existing caps.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Uses a file-local <see cref="StaticMonitor"/> helper mirroring the
|
|
||||||
/// convention in the sibling Payload tests (TruncationTests,
|
|
||||||
/// FilterIntegrationTests, BodyRegexRedactionTests, etc.) — the
|
|
||||||
/// <c>TestOptionsMonitor<T></c> helper referenced by the plan is a
|
|
||||||
/// private nested class inside <c>AuditLogOptionsBindingTests</c> and thus
|
|
||||||
/// not reachable from this file.
|
|
||||||
/// </remarks>
|
|
||||||
public class InboundChannelCapTests
|
|
||||||
{
|
|
||||||
private static AuditEvent MakeInbound(
|
|
||||||
AuditStatus status,
|
|
||||||
string? request = null,
|
|
||||||
string? response = null) =>
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
EventId = Guid.NewGuid(),
|
|
||||||
OccurredAtUtc = DateTime.UtcNow,
|
|
||||||
Channel = AuditChannel.ApiInbound,
|
|
||||||
Kind = AuditKind.InboundRequest,
|
|
||||||
Status = status,
|
|
||||||
RequestSummary = request,
|
|
||||||
ResponseSummary = response,
|
|
||||||
};
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ApiInbound_Delivered_RequestBody_BelowInboundMaxBytes_NotTruncated()
|
|
||||||
{
|
|
||||||
// Body well above the legacy 8 KiB default cap but under the 1 MiB
|
|
||||||
// inbound ceiling — must NOT truncate.
|
|
||||||
var body = new string('a', 100_000);
|
|
||||||
var opts = new AuditLogOptions(); // defaults
|
|
||||||
var filter = new DefaultAuditPayloadFilter(
|
|
||||||
new StaticMonitor(opts),
|
|
||||||
NullLogger<DefaultAuditPayloadFilter>.Instance);
|
|
||||||
|
|
||||||
var result = filter.Apply(MakeInbound(AuditStatus.Delivered, request: body));
|
|
||||||
|
|
||||||
Assert.False(result.PayloadTruncated);
|
|
||||||
Assert.Equal(100_000, Encoding.UTF8.GetByteCount(result.RequestSummary!));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ApiInbound_Delivered_ResponseBody_BelowInboundMaxBytes_NotTruncated()
|
|
||||||
{
|
|
||||||
var body = new string('a', 100_000);
|
|
||||||
var opts = new AuditLogOptions();
|
|
||||||
var filter = new DefaultAuditPayloadFilter(
|
|
||||||
new StaticMonitor(opts),
|
|
||||||
NullLogger<DefaultAuditPayloadFilter>.Instance);
|
|
||||||
|
|
||||||
var result = filter.Apply(MakeInbound(AuditStatus.Delivered, response: body));
|
|
||||||
|
|
||||||
Assert.False(result.PayloadTruncated);
|
|
||||||
Assert.Equal(100_000, Encoding.UTF8.GetByteCount(result.ResponseSummary!));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ApiInbound_Failed_BodyAboveInboundMaxBytes_TruncatedToInboundMaxBytes()
|
|
||||||
{
|
|
||||||
// Even on error rows, the inbound cap is InboundMaxBytes (NOT ErrorCapBytes).
|
|
||||||
var opts = new AuditLogOptions { InboundMaxBytes = 16_384 };
|
|
||||||
var oversized = new string('z', 50_000);
|
|
||||||
var filter = new DefaultAuditPayloadFilter(
|
|
||||||
new StaticMonitor(opts),
|
|
||||||
NullLogger<DefaultAuditPayloadFilter>.Instance);
|
|
||||||
|
|
||||||
var result = filter.Apply(MakeInbound(AuditStatus.Failed, response: oversized));
|
|
||||||
|
|
||||||
Assert.True(result.PayloadTruncated);
|
|
||||||
Assert.True(Encoding.UTF8.GetByteCount(result.ResponseSummary!) <= 16_384);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ApiOutbound_StillUsesDefaultCap_NotInboundMaxBytes()
|
|
||||||
{
|
|
||||||
// Regression guard: lifting the inbound cap MUST NOT change other
|
|
||||||
// channels. An ApiOutbound 100 KB body still hits the 8 KiB cap.
|
|
||||||
var opts = new AuditLogOptions();
|
|
||||||
var body = new string('a', 100_000);
|
|
||||||
var filter = new DefaultAuditPayloadFilter(
|
|
||||||
new StaticMonitor(opts),
|
|
||||||
NullLogger<DefaultAuditPayloadFilter>.Instance);
|
|
||||||
|
|
||||||
var evt = new AuditEvent
|
|
||||||
{
|
|
||||||
EventId = Guid.NewGuid(),
|
|
||||||
OccurredAtUtc = DateTime.UtcNow,
|
|
||||||
Channel = AuditChannel.ApiOutbound,
|
|
||||||
Kind = AuditKind.ApiCall,
|
|
||||||
Status = AuditStatus.Delivered,
|
|
||||||
RequestSummary = body,
|
|
||||||
};
|
|
||||||
var result = filter.Apply(evt);
|
|
||||||
|
|
||||||
Assert.True(result.PayloadTruncated);
|
|
||||||
Assert.True(Encoding.UTF8.GetByteCount(result.RequestSummary!) <= opts.DefaultCapBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// IOptionsMonitor test double — returns the same snapshot on every read,
|
|
||||||
/// no change-token plumbing required for these tests. Mirrors the helper
|
|
||||||
/// used in <c>TruncationTests</c>, <c>FilterIntegrationTests</c>, etc.
|
|
||||||
/// </summary>
|
|
||||||
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
|
|
||||||
{
|
|
||||||
private readonly AuditLogOptions _value;
|
|
||||||
|
|
||||||
public StaticMonitor(AuditLogOptions value) => _value = value;
|
|
||||||
|
|
||||||
public AuditLogOptions CurrentValue => _value;
|
|
||||||
|
|
||||||
public AuditLogOptions Get(string? name) => _value;
|
|
||||||
|
|
||||||
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
using System.Linq;
|
|
||||||
using System.Reflection;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Bundle A (M5-T1) contract test for <see cref="IAuditPayloadFilter"/>. The
|
|
||||||
/// interface is the seam between event construction and writer persistence;
|
|
||||||
/// later bundles register the production implementation as a singleton and
|
|
||||||
/// invoke it from the site/central writer paths. We pin the surface area here
|
|
||||||
/// via reflection so accidental signature drift breaks the build before the
|
|
||||||
/// downstream wiring goes red.
|
|
||||||
/// </summary>
|
|
||||||
public class PayloadFilterContractTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Interface_Exists_InPayloadNamespace()
|
|
||||||
{
|
|
||||||
var type = typeof(IAuditPayloadFilter);
|
|
||||||
|
|
||||||
Assert.True(type.IsInterface, "IAuditPayloadFilter must be an interface");
|
|
||||||
Assert.Equal("ZB.MOM.WW.ScadaBridge.AuditLog.Payload", type.Namespace);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Apply_Method_HasDocumentedSignature()
|
|
||||||
{
|
|
||||||
var type = typeof(IAuditPayloadFilter);
|
|
||||||
|
|
||||||
var method = type.GetMethod(
|
|
||||||
"Apply",
|
|
||||||
BindingFlags.Instance | BindingFlags.Public,
|
|
||||||
binder: null,
|
|
||||||
types: new[] { typeof(AuditEvent) },
|
|
||||||
modifiers: null);
|
|
||||||
|
|
||||||
Assert.NotNull(method);
|
|
||||||
Assert.Equal(typeof(AuditEvent), method!.ReturnType);
|
|
||||||
|
|
||||||
var parameters = method.GetParameters();
|
|
||||||
Assert.Single(parameters);
|
|
||||||
Assert.Equal("rawEvent", parameters[0].Name);
|
|
||||||
Assert.Equal(typeof(AuditEvent), parameters[0].ParameterType);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Interface_DeclaresExactlyOneMethod()
|
|
||||||
{
|
|
||||||
var type = typeof(IAuditPayloadFilter);
|
|
||||||
var methods = type.GetMethods(BindingFlags.Instance | BindingFlags.Public)
|
|
||||||
.Where(m => !m.IsSpecialName)
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
Assert.Single(methods);
|
|
||||||
Assert.Equal("Apply", methods[0].Name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Bundle D (M5-T10) safety-net edge cases for
|
|
||||||
/// <see cref="DefaultAuditPayloadFilter"/>. Bundle B already pinned the
|
|
||||||
/// happy-path safety net (catastrophic-backtracking timeout →
|
|
||||||
/// <c><redacted: redactor error></c> + counter bump); this fixture covers
|
|
||||||
/// the pathological / config-mistake corners that production operators will
|
|
||||||
/// hit when typoing a regex or shipping a half-baked redactor list.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// The invariants under test:
|
|
||||||
/// </para>
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item>An UNCOMPILABLE pattern (e.g. <c>[unclosed</c>) is logged at warning
|
|
||||||
/// on first encounter and cached as invalid so it never throws again,
|
|
||||||
/// but the redactor-failure COUNTER is not bumped at bind time —
|
|
||||||
/// per the contract on <see cref="IAuditRedactionFailureCounter"/>
|
|
||||||
/// the counter tracks RUNTIME redaction failures only.</item>
|
|
||||||
/// <item>One throwing regex in the middle of a list does NOT poison the
|
|
||||||
/// other patterns — the filter stops at the failing pattern,
|
|
||||||
/// over-redacts the offending field, but lets every other field keep
|
|
||||||
/// the prior cleanly-redacted state and lets the rest of the writer
|
|
||||||
/// pipeline run.</item>
|
|
||||||
/// <item>A live config change that introduces a broken pattern does not
|
|
||||||
/// crash the filter — the bad pattern is silently dropped (logged once)
|
|
||||||
/// and the still-valid patterns continue to redact normally.</item>
|
|
||||||
/// </list>
|
|
||||||
/// </remarks>
|
|
||||||
public class RedactionSafetyNetTests
|
|
||||||
{
|
|
||||||
private static IOptionsMonitor<AuditLogOptions> Monitor(AuditLogOptions? opts = null) =>
|
|
||||||
new StaticMonitor(opts ?? new AuditLogOptions());
|
|
||||||
|
|
||||||
private static AuditEvent NewEvent(
|
|
||||||
AuditStatus status = AuditStatus.Delivered,
|
|
||||||
string? request = null,
|
|
||||||
string? response = null,
|
|
||||||
string? errorDetail = null,
|
|
||||||
string? extra = null,
|
|
||||||
string? target = null) => new()
|
|
||||||
{
|
|
||||||
EventId = Guid.NewGuid(),
|
|
||||||
OccurredAtUtc = DateTime.UtcNow,
|
|
||||||
Channel = AuditChannel.ApiOutbound,
|
|
||||||
Kind = AuditKind.ApiCall,
|
|
||||||
Status = status,
|
|
||||||
Target = target,
|
|
||||||
RequestSummary = request,
|
|
||||||
ResponseSummary = response,
|
|
||||||
ErrorDetail = errorDetail,
|
|
||||||
Extra = extra,
|
|
||||||
};
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RegexNotCompilable_AtBindTime_LoggedAndSkipped()
|
|
||||||
{
|
|
||||||
// `[unclosed` is a structurally invalid character class — the .NET
|
|
||||||
// regex engine throws ArgumentException at compile time. We assert:
|
|
||||||
// * the filter does NOT throw,
|
|
||||||
// * the OTHER (valid) pattern still redacts hunter2,
|
|
||||||
// * the failure counter is NOT incremented at compile time
|
|
||||||
// (it tracks runtime redaction failures only),
|
|
||||||
// * a warning is logged exactly once.
|
|
||||||
const string badPattern = "[unclosed";
|
|
||||||
const string goodPattern = "\"password\":\\s*\"[^\"]*\"";
|
|
||||||
var opts = new AuditLogOptions
|
|
||||||
{
|
|
||||||
GlobalBodyRedactors = new List<string> { badPattern, goodPattern },
|
|
||||||
};
|
|
||||||
var counter = new CountingRedactionFailureCounter();
|
|
||||||
var spy = new SpyLogger<DefaultAuditPayloadFilter>();
|
|
||||||
var filter = new DefaultAuditPayloadFilter(Monitor(opts), spy, counter);
|
|
||||||
|
|
||||||
var evt = NewEvent(request: "{\"user\":\"alice\",\"password\":\"hunter2\"}");
|
|
||||||
|
|
||||||
var result = filter.Apply(evt);
|
|
||||||
|
|
||||||
Assert.NotNull(result.RequestSummary);
|
|
||||||
Assert.DoesNotContain("hunter2", result.RequestSummary);
|
|
||||||
Assert.Contains("<redacted>", result.RequestSummary);
|
|
||||||
Assert.Equal(0, counter.Count);
|
|
||||||
// Apply twice — the invalid-pattern compile must run AT MOST once;
|
|
||||||
// the sentinel-cache entry stops repeat compile attempts.
|
|
||||||
_ = filter.Apply(evt);
|
|
||||||
var badPatternWarnings = spy.Entries
|
|
||||||
.Where(e => e.Level == LogLevel.Warning && e.Message.Contains(badPattern))
|
|
||||||
.Count();
|
|
||||||
Assert.Equal(1, badPatternWarnings);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void MultipleRedactors_OneThrows_OthersStillApply_ToOtherFields()
|
|
||||||
{
|
|
||||||
// Pattern set: [valid-A, evil, valid-B]. The evil pattern is
|
|
||||||
// catastrophic-backtracking on the RequestSummary input (all-'a's +
|
|
||||||
// mismatching suffix) — that field is over-redacted with the error
|
|
||||||
// marker as soon as evil throws. ResponseSummary is processed
|
|
||||||
// INDEPENDENTLY; its input does not trigger evil's backtracking, so
|
|
||||||
// valid-A and valid-B both still apply on that field. This proves a
|
|
||||||
// per-field redactor failure does not poison the rest of the writer
|
|
||||||
// call (the SQL-param stage, the truncation stage, and the other
|
|
||||||
// fields all continue normally).
|
|
||||||
const string validA = "SECRET-[A-Z0-9]+";
|
|
||||||
const string evil = "^(a+)+$"; // catastrophic on long all-'a' string
|
|
||||||
const string validB = "PIN-\\d{4}";
|
|
||||||
var opts = new AuditLogOptions
|
|
||||||
{
|
|
||||||
GlobalBodyRedactors = new List<string> { validA, evil, validB },
|
|
||||||
};
|
|
||||||
var counter = new CountingRedactionFailureCounter();
|
|
||||||
var filter = new DefaultAuditPayloadFilter(
|
|
||||||
Monitor(opts),
|
|
||||||
NullLogger<DefaultAuditPayloadFilter>.Instance,
|
|
||||||
counter);
|
|
||||||
|
|
||||||
// Request: ALL 'a's + a non-'a' suffix character. valid-A does not
|
|
||||||
// match (no SECRET-X prefix), so the buffer reaches `evil` untouched
|
|
||||||
// and triggers the backtracking explosion.
|
|
||||||
var request = new string('a', 30) + "!";
|
|
||||||
// Response: short, mismatches the evil pattern cleanly (no
|
|
||||||
// backtracking), so both valid-A and valid-B run and redact.
|
|
||||||
const string response = "SECRET-ABC456 PIN-9999 other-text";
|
|
||||||
|
|
||||||
var result = filter.Apply(NewEvent(request: request, response: response));
|
|
||||||
|
|
||||||
// RequestSummary: over-redacted (evil pattern threw).
|
|
||||||
Assert.Equal("<redacted: redactor error>", result.RequestSummary);
|
|
||||||
Assert.True(counter.Count >= 1, $"expected counter >= 1, got {counter.Count}");
|
|
||||||
|
|
||||||
// ResponseSummary: clean — both valid regexes still applied; the evil
|
|
||||||
// one ran without throwing on this short input.
|
|
||||||
Assert.NotNull(result.ResponseSummary);
|
|
||||||
Assert.DoesNotContain("SECRET-ABC456", result.ResponseSummary);
|
|
||||||
Assert.DoesNotContain("PIN-9999", result.ResponseSummary);
|
|
||||||
Assert.Contains("<redacted>", result.ResponseSummary);
|
|
||||||
Assert.Contains("other-text", result.ResponseSummary);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edge case 3 (RedactorReturnsNonStringExceptionType) intentionally
|
|
||||||
// skipped — the brief permits dropping it: there is no portable way to
|
|
||||||
// artificially trigger an OutOfMemoryException inside System.Text.RegularExpressions
|
|
||||||
// from a unit test without writing native interop, and the existing
|
|
||||||
// per-stage try/catch already covers Exception (which OOM and similar
|
|
||||||
// would derive from). Bundle B's RegexThrowsTimeout coverage exercises
|
|
||||||
// the same catch path with a deterministic trigger.
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ConfigChange_WithBadRegex_LiveTrafficKeepsApplyingValidRegexes()
|
|
||||||
{
|
|
||||||
// Initial config: one valid global redactor — hunter2 is redacted.
|
|
||||||
// Reload: ADD a malformed pattern alongside the original. Per the
|
|
||||||
// safety contract, the bad pattern is logged + skipped, the original
|
|
||||||
// valid pattern keeps redacting, and the filter NEVER throws on the
|
|
||||||
// hot path. The counter must not be bumped at reload time (the
|
|
||||||
// CompiledRegex sentinel covers the bind error before runtime even
|
|
||||||
// sees it).
|
|
||||||
var monitor = new MutableMonitor(new AuditLogOptions
|
|
||||||
{
|
|
||||||
GlobalBodyRedactors = new List<string> { "\"password\":\\s*\"[^\"]*\"" },
|
|
||||||
});
|
|
||||||
var counter = new CountingRedactionFailureCounter();
|
|
||||||
var spy = new SpyLogger<DefaultAuditPayloadFilter>();
|
|
||||||
var filter = new DefaultAuditPayloadFilter(monitor, spy, counter);
|
|
||||||
|
|
||||||
var evt = NewEvent(request: "{\"user\":\"alice\",\"password\":\"hunter2\"}");
|
|
||||||
|
|
||||||
var before = filter.Apply(evt);
|
|
||||||
Assert.DoesNotContain("hunter2", before.RequestSummary!);
|
|
||||||
|
|
||||||
// Reload: malformed pattern added to the list.
|
|
||||||
monitor.Set(new AuditLogOptions
|
|
||||||
{
|
|
||||||
GlobalBodyRedactors = new List<string>
|
|
||||||
{
|
|
||||||
"\"password\":\\s*\"[^\"]*\"",
|
|
||||||
"[unclosed",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
var after = filter.Apply(evt);
|
|
||||||
Assert.NotNull(after.RequestSummary);
|
|
||||||
Assert.DoesNotContain("hunter2", after.RequestSummary);
|
|
||||||
Assert.Contains("<redacted>", after.RequestSummary);
|
|
||||||
Assert.Equal(0, counter.Count);
|
|
||||||
// Compile-time warning logged for the broken pattern.
|
|
||||||
Assert.Contains(
|
|
||||||
spy.Entries,
|
|
||||||
e => e.Level == LogLevel.Warning && e.Message.Contains("[unclosed"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Counts <see cref="IAuditRedactionFailureCounter.Increment"/> calls.</summary>
|
|
||||||
private sealed class CountingRedactionFailureCounter : IAuditRedactionFailureCounter
|
|
||||||
{
|
|
||||||
private int _count;
|
|
||||||
public int Count => _count;
|
|
||||||
public void Increment() => System.Threading.Interlocked.Increment(ref _count);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>IOptionsMonitor test double — returns the same snapshot on every read.</summary>
|
|
||||||
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
|
|
||||||
{
|
|
||||||
private readonly AuditLogOptions _value;
|
|
||||||
public StaticMonitor(AuditLogOptions value) => _value = value;
|
|
||||||
public AuditLogOptions CurrentValue => _value;
|
|
||||||
public AuditLogOptions Get(string? name) => _value;
|
|
||||||
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// IOptionsMonitor test double that supports a live <see cref="Set"/> —
|
|
||||||
/// mirrors the helper used in
|
|
||||||
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration.AuditLogOptionsBindingTests"/>;
|
|
||||||
/// kept private here so the safety-net test file remains self-contained.
|
|
||||||
/// </summary>
|
|
||||||
private sealed class MutableMonitor : IOptionsMonitor<AuditLogOptions>
|
|
||||||
{
|
|
||||||
private AuditLogOptions _current;
|
|
||||||
public MutableMonitor(AuditLogOptions initial) => _current = initial;
|
|
||||||
public AuditLogOptions CurrentValue => _current;
|
|
||||||
public AuditLogOptions Get(string? name) => _current;
|
|
||||||
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
|
||||||
public void Set(AuditLogOptions value) => _current = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Minimal ILogger that records each formatted log line so tests can
|
|
||||||
/// assert on the compile-time warning emission contract — counting
|
|
||||||
/// warnings and grepping the message text.
|
|
||||||
/// </summary>
|
|
||||||
private sealed class SpyLogger<T> : ILogger<T>
|
|
||||||
{
|
|
||||||
private readonly List<LogEntry> _entries = new();
|
|
||||||
|
|
||||||
public IReadOnlyList<LogEntry> Entries
|
|
||||||
{
|
|
||||||
get { lock (_entries) return _entries.ToArray(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
|
||||||
public bool IsEnabled(LogLevel logLevel) => true;
|
|
||||||
public void Log<TState>(
|
|
||||||
LogLevel logLevel,
|
|
||||||
EventId eventId,
|
|
||||||
TState state,
|
|
||||||
Exception? exception,
|
|
||||||
Func<TState, Exception?, string> formatter)
|
|
||||||
{
|
|
||||||
var msg = formatter(state, exception);
|
|
||||||
lock (_entries) _entries.Add(new LogEntry(logLevel, msg));
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class NullScope : IDisposable
|
|
||||||
{
|
|
||||||
public static readonly NullScope Instance = new();
|
|
||||||
public void Dispose() { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record LogEntry(LogLevel Level, string Message);
|
|
||||||
}
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Bundle B (M5-T5) tests for SQL parameter redaction in
|
|
||||||
/// <see cref="DefaultAuditPayloadFilter"/>. M4 Bundle A's
|
|
||||||
/// <c>AuditingDbCommand</c> emits <c>RequestSummary</c> as
|
|
||||||
/// <c>{"sql":"...","parameters":{"@name":"value", ...}}</c>; the SQL-parameter
|
|
||||||
/// redactor parses this shape on
|
|
||||||
/// <see cref="AuditChannel.DbOutbound"/> rows, replaces values whose key
|
|
||||||
/// matches the configured case-insensitive regex with <c><redacted></c>,
|
|
||||||
/// and re-serialises. Default behaviour with no opt-in: parameter values are
|
|
||||||
/// captured verbatim. Connection lookup uses the connection-name prefix of
|
|
||||||
/// <see cref="AuditEvent.Target"/> (everything before the first <c>.</c>) so
|
|
||||||
/// the same per-connection regex applies regardless of the SQL-snippet suffix
|
|
||||||
/// that <c>AuditingDbCommand</c> appends to disambiguate rows.
|
|
||||||
/// </summary>
|
|
||||||
public class SqlParamRedactionTests
|
|
||||||
{
|
|
||||||
private static IOptionsMonitor<AuditLogOptions> Monitor(AuditLogOptions? opts = null) =>
|
|
||||||
new StaticMonitor(opts ?? new AuditLogOptions());
|
|
||||||
|
|
||||||
private static DefaultAuditPayloadFilter Filter(AuditLogOptions? opts = null) =>
|
|
||||||
new(Monitor(opts), NullLogger<DefaultAuditPayloadFilter>.Instance);
|
|
||||||
|
|
||||||
private static AuditEvent NewDbEvent(string target, string requestSummary) => new()
|
|
||||||
{
|
|
||||||
EventId = Guid.NewGuid(),
|
|
||||||
OccurredAtUtc = DateTime.UtcNow,
|
|
||||||
Channel = AuditChannel.DbOutbound,
|
|
||||||
Kind = AuditKind.DbWrite,
|
|
||||||
Status = AuditStatus.Delivered,
|
|
||||||
Target = target,
|
|
||||||
RequestSummary = requestSummary,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Build a RequestSummary in the exact shape M4's <c>AuditingDbCommand</c>
|
|
||||||
/// emits — hand-rolled JSON with <c>"sql"</c> + <c>"parameters"</c> keys.
|
|
||||||
/// Tests depend on this format; if AuditingDbCommand ever changes, this
|
|
||||||
/// helper updates in lockstep.
|
|
||||||
/// </summary>
|
|
||||||
private static string DbRequestSummary(string sql, params (string name, string value)[] parameters)
|
|
||||||
{
|
|
||||||
var sb = new System.Text.StringBuilder();
|
|
||||||
sb.Append("{\"sql\":\"").Append(sql).Append('"');
|
|
||||||
if (parameters.Length > 0)
|
|
||||||
{
|
|
||||||
sb.Append(",\"parameters\":{");
|
|
||||||
for (var i = 0; i < parameters.Length; i++)
|
|
||||||
{
|
|
||||||
if (i > 0) sb.Append(',');
|
|
||||||
sb.Append('"').Append(parameters[i].name).Append("\":\"")
|
|
||||||
.Append(parameters[i].value).Append('"');
|
|
||||||
}
|
|
||||||
sb.Append('}');
|
|
||||||
}
|
|
||||||
sb.Append('}');
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NoOptIn_ParamsVerbatim_Unchanged()
|
|
||||||
{
|
|
||||||
var input = DbRequestSummary(
|
|
||||||
"INSERT INTO Users (Name, Token) VALUES (@name, @token)",
|
|
||||||
("@name", "Alice"),
|
|
||||||
("@token", "secret-xyz"));
|
|
||||||
var evt = NewDbEvent("PrimaryDb.INSERT INTO Users", input);
|
|
||||||
|
|
||||||
var result = Filter().Apply(evt);
|
|
||||||
|
|
||||||
Assert.Equal(input, result.RequestSummary);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void OptInRegex_AtToken_OrAtApikey_RedactsThoseValues_KeepsOthers()
|
|
||||||
{
|
|
||||||
var opts = new AuditLogOptions
|
|
||||||
{
|
|
||||||
PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
|
|
||||||
{
|
|
||||||
["PrimaryDb"] = new PerTargetRedactionOverride
|
|
||||||
{
|
|
||||||
RedactSqlParamsMatching = "^@(token|apikey)$",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
var input = DbRequestSummary(
|
|
||||||
"INSERT INTO Users (Name, Token, ApiKey) VALUES (@name, @token, @apikey)",
|
|
||||||
("@name", "Alice"),
|
|
||||||
("@token", "secret-xyz"),
|
|
||||||
("@apikey", "k-987"));
|
|
||||||
var evt = NewDbEvent("PrimaryDb.INSERT INTO Users", input);
|
|
||||||
|
|
||||||
var result = Filter(opts).Apply(evt);
|
|
||||||
|
|
||||||
Assert.NotNull(result.RequestSummary);
|
|
||||||
Assert.Contains("\"@name\":\"Alice\"", result.RequestSummary);
|
|
||||||
Assert.Contains("\"@token\":\"<redacted>\"", result.RequestSummary);
|
|
||||||
Assert.Contains("\"@apikey\":\"<redacted>\"", result.RequestSummary);
|
|
||||||
Assert.DoesNotContain("secret-xyz", result.RequestSummary);
|
|
||||||
Assert.DoesNotContain("k-987", result.RequestSummary);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RegexCaseInsensitive_MatchesParamNames()
|
|
||||||
{
|
|
||||||
var opts = new AuditLogOptions
|
|
||||||
{
|
|
||||||
PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
|
|
||||||
{
|
|
||||||
["PrimaryDb"] = new PerTargetRedactionOverride
|
|
||||||
{
|
|
||||||
RedactSqlParamsMatching = "token",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
var input = DbRequestSummary(
|
|
||||||
"UPDATE x SET Token = @TOKEN",
|
|
||||||
("@TOKEN", "uppercased-secret"));
|
|
||||||
var evt = NewDbEvent("PrimaryDb.UPDATE x SET Token", input);
|
|
||||||
|
|
||||||
var result = Filter(opts).Apply(evt);
|
|
||||||
|
|
||||||
Assert.NotNull(result.RequestSummary);
|
|
||||||
Assert.Contains("\"@TOKEN\":\"<redacted>\"", result.RequestSummary);
|
|
||||||
Assert.DoesNotContain("uppercased-secret", result.RequestSummary);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NonDbOutboundChannel_NotAffected()
|
|
||||||
{
|
|
||||||
// ApiOutbound row whose RequestSummary happens to look like the
|
|
||||||
// DbOutbound JSON shape (worst-case false positive). The SQL
|
|
||||||
// redactor must NOT touch it — channel guards the stage.
|
|
||||||
var opts = new AuditLogOptions
|
|
||||||
{
|
|
||||||
PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
|
|
||||||
{
|
|
||||||
["PrimaryDb"] = new PerTargetRedactionOverride
|
|
||||||
{
|
|
||||||
RedactSqlParamsMatching = "^@token$",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
var input = DbRequestSummary(
|
|
||||||
"SELECT @token",
|
|
||||||
("@token", "should-survive"));
|
|
||||||
var evt = new AuditEvent
|
|
||||||
{
|
|
||||||
EventId = Guid.NewGuid(),
|
|
||||||
OccurredAtUtc = DateTime.UtcNow,
|
|
||||||
Channel = AuditChannel.ApiOutbound,
|
|
||||||
Kind = AuditKind.ApiCall,
|
|
||||||
Status = AuditStatus.Delivered,
|
|
||||||
Target = "PrimaryDb.SELECT", // doesn't matter — channel guards
|
|
||||||
RequestSummary = input,
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = Filter(opts).Apply(evt);
|
|
||||||
|
|
||||||
Assert.Equal(input, result.RequestSummary);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void PerTargetSetting_MatchesByTarget()
|
|
||||||
{
|
|
||||||
// Two connections — A is configured to redact tokens, B is not. Same
|
|
||||||
// payload through each must yield different results.
|
|
||||||
var opts = new AuditLogOptions
|
|
||||||
{
|
|
||||||
PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
|
|
||||||
{
|
|
||||||
["ConnA"] = new PerTargetRedactionOverride
|
|
||||||
{
|
|
||||||
RedactSqlParamsMatching = "^@token$",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
var input = DbRequestSummary(
|
|
||||||
"SELECT @token",
|
|
||||||
("@token", "the-secret"));
|
|
||||||
|
|
||||||
var aEvt = NewDbEvent("ConnA.SELECT @token", input);
|
|
||||||
var bEvt = NewDbEvent("ConnB.SELECT @token", input);
|
|
||||||
|
|
||||||
var aResult = Filter(opts).Apply(aEvt);
|
|
||||||
var bResult = Filter(opts).Apply(bEvt);
|
|
||||||
|
|
||||||
Assert.Contains("<redacted>", aResult.RequestSummary!);
|
|
||||||
Assert.DoesNotContain("the-secret", aResult.RequestSummary!);
|
|
||||||
|
|
||||||
Assert.Equal(input, bResult.RequestSummary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>IOptionsMonitor test double.</summary>
|
|
||||||
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
|
|
||||||
{
|
|
||||||
private readonly AuditLogOptions _value;
|
|
||||||
public StaticMonitor(AuditLogOptions value) => _value = value;
|
|
||||||
public AuditLogOptions CurrentValue => _value;
|
|
||||||
public AuditLogOptions Get(string? name) => _value;
|
|
||||||
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Payload;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Bundle A (M5-T2) tests for <see cref="DefaultAuditPayloadFilter"/> truncation.
|
|
||||||
/// The filter caps RequestSummary / ResponseSummary / ErrorDetail / Extra at
|
|
||||||
/// <see cref="AuditLogOptions.DefaultCapBytes"/> (8 KiB) on success rows and
|
|
||||||
/// <see cref="AuditLogOptions.ErrorCapBytes"/> (64 KiB) on error rows. "Error
|
|
||||||
/// row" = <see cref="AuditEvent.Status"/> NOT IN (<c>Delivered</c>,
|
|
||||||
/// <c>Submitted</c>, <c>Forwarded</c>). Truncation must respect UTF-8 character
|
|
||||||
/// boundaries (never split a multi-byte sequence mid-character) and must set
|
|
||||||
/// <see cref="AuditEvent.PayloadTruncated"/> true when any field is shortened.
|
|
||||||
/// </summary>
|
|
||||||
public class TruncationTests
|
|
||||||
{
|
|
||||||
private static IOptionsMonitor<AuditLogOptions> Monitor(AuditLogOptions? opts = null)
|
|
||||||
{
|
|
||||||
var snapshot = opts ?? new AuditLogOptions();
|
|
||||||
return new StaticMonitor(snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DefaultAuditPayloadFilter Filter(AuditLogOptions? opts = null) =>
|
|
||||||
new(Monitor(opts), NullLogger<DefaultAuditPayloadFilter>.Instance);
|
|
||||||
|
|
||||||
private static AuditEvent NewEvent(
|
|
||||||
AuditStatus status = AuditStatus.Delivered,
|
|
||||||
string? request = null,
|
|
||||||
string? response = null,
|
|
||||||
string? errorDetail = null,
|
|
||||||
string? extra = null,
|
|
||||||
bool payloadTruncated = false) => new()
|
|
||||||
{
|
|
||||||
EventId = Guid.NewGuid(),
|
|
||||||
OccurredAtUtc = DateTime.UtcNow,
|
|
||||||
Channel = AuditChannel.ApiOutbound,
|
|
||||||
Kind = AuditKind.ApiCall,
|
|
||||||
Status = status,
|
|
||||||
RequestSummary = request,
|
|
||||||
ResponseSummary = response,
|
|
||||||
ErrorDetail = errorDetail,
|
|
||||||
Extra = extra,
|
|
||||||
PayloadTruncated = payloadTruncated,
|
|
||||||
};
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SuccessRow_10KB_RequestSummary_TruncatedTo8KB_PayloadTruncatedTrue()
|
|
||||||
{
|
|
||||||
var input = new string('a', 10 * 1024);
|
|
||||||
var evt = NewEvent(AuditStatus.Delivered, request: input);
|
|
||||||
|
|
||||||
var result = Filter().Apply(evt);
|
|
||||||
|
|
||||||
Assert.NotNull(result.RequestSummary);
|
|
||||||
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.RequestSummary!));
|
|
||||||
Assert.True(result.PayloadTruncated);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ErrorRow_10KB_RequestSummary_NotTruncated_UnderErrorCap()
|
|
||||||
{
|
|
||||||
var input = new string('b', 10 * 1024);
|
|
||||||
var evt = NewEvent(AuditStatus.Failed, request: input);
|
|
||||||
|
|
||||||
var result = Filter().Apply(evt);
|
|
||||||
|
|
||||||
Assert.Equal(input, result.RequestSummary);
|
|
||||||
Assert.False(result.PayloadTruncated);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ErrorRow_70KB_RequestSummary_TruncatedTo64KB_PayloadTruncatedTrue()
|
|
||||||
{
|
|
||||||
var input = new string('c', 70 * 1024);
|
|
||||||
var evt = NewEvent(AuditStatus.Failed, request: input);
|
|
||||||
|
|
||||||
var result = Filter().Apply(evt);
|
|
||||||
|
|
||||||
Assert.NotNull(result.RequestSummary);
|
|
||||||
Assert.Equal(65536, Encoding.UTF8.GetByteCount(result.RequestSummary!));
|
|
||||||
Assert.True(result.PayloadTruncated);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Multibyte_UTF8_TruncatedAtCharacterBoundary_NotMidByte()
|
|
||||||
{
|
|
||||||
// U+1F600 (grinning face) encodes to 4 UTF-8 bytes; 2000 of them = 8000 bytes,
|
|
||||||
// safely under the 8192 default cap so the boundary scan kicks in mid-character
|
|
||||||
// when we push past it. Pad with a few extra emoji so the *input* is > 8192 bytes
|
|
||||||
// and forces truncation.
|
|
||||||
var emoji = "😀"; // surrogate pair => one code point => 4 UTF-8 bytes
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
for (int i = 0; i < 2100; i++)
|
|
||||||
{
|
|
||||||
sb.Append(emoji);
|
|
||||||
}
|
|
||||||
var input = sb.ToString();
|
|
||||||
Assert.True(Encoding.UTF8.GetByteCount(input) > 8192);
|
|
||||||
|
|
||||||
var evt = NewEvent(AuditStatus.Delivered, request: input);
|
|
||||||
|
|
||||||
var result = Filter().Apply(evt);
|
|
||||||
|
|
||||||
Assert.NotNull(result.RequestSummary);
|
|
||||||
var resultBytes = Encoding.UTF8.GetByteCount(result.RequestSummary!);
|
|
||||||
Assert.True(resultBytes <= 8192, $"expected <= 8192 bytes, got {resultBytes}");
|
|
||||||
// 4-byte emoji boundary: the kept byte length must be a multiple of 4.
|
|
||||||
Assert.Equal(0, resultBytes % 4);
|
|
||||||
// And round-tripping the result must not introduce a U+FFFD replacement char.
|
|
||||||
Assert.DoesNotContain('�', result.RequestSummary);
|
|
||||||
Assert.True(result.PayloadTruncated);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NullSummary_PassesThrough_AsNull()
|
|
||||||
{
|
|
||||||
var evt = NewEvent(AuditStatus.Delivered, request: null, response: null, errorDetail: null, extra: null);
|
|
||||||
|
|
||||||
var result = Filter().Apply(evt);
|
|
||||||
|
|
||||||
Assert.Null(result.RequestSummary);
|
|
||||||
Assert.Null(result.ResponseSummary);
|
|
||||||
Assert.Null(result.ErrorDetail);
|
|
||||||
Assert.Null(result.Extra);
|
|
||||||
Assert.False(result.PayloadTruncated);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RawEventAlreadyTruncated_PayloadTruncatedRemainsTrue()
|
|
||||||
{
|
|
||||||
// Small payload that requires no truncation, but the caller already
|
|
||||||
// flagged PayloadTruncated upstream — the filter must not clear it.
|
|
||||||
var evt = NewEvent(AuditStatus.Delivered, request: "small", payloadTruncated: true);
|
|
||||||
|
|
||||||
var result = Filter().Apply(evt);
|
|
||||||
|
|
||||||
Assert.Equal("small", result.RequestSummary);
|
|
||||||
Assert.True(result.PayloadTruncated);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void StatusAttempted_TreatedAsError_UsesErrorCap()
|
|
||||||
{
|
|
||||||
// 10 KB is under the 64 KB error cap; if Attempted were a success status
|
|
||||||
// the value would be truncated to 8 KB. We assert it is NOT truncated.
|
|
||||||
var input = new string('d', 10 * 1024);
|
|
||||||
var evt = NewEvent(AuditStatus.Attempted, request: input);
|
|
||||||
|
|
||||||
var result = Filter().Apply(evt);
|
|
||||||
|
|
||||||
Assert.Equal(input, result.RequestSummary);
|
|
||||||
Assert.False(result.PayloadTruncated);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void StatusParked_TreatedAsError_UsesErrorCap()
|
|
||||||
{
|
|
||||||
var input = new string('e', 10 * 1024);
|
|
||||||
var evt = NewEvent(AuditStatus.Parked, request: input);
|
|
||||||
|
|
||||||
var result = Filter().Apply(evt);
|
|
||||||
|
|
||||||
Assert.Equal(input, result.RequestSummary);
|
|
||||||
Assert.False(result.PayloadTruncated);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void StatusSkipped_TreatedAsError_UsesErrorCap()
|
|
||||||
{
|
|
||||||
var input = new string('f', 10 * 1024);
|
|
||||||
var evt = NewEvent(AuditStatus.Skipped, request: input);
|
|
||||||
|
|
||||||
var result = Filter().Apply(evt);
|
|
||||||
|
|
||||||
Assert.Equal(input, result.RequestSummary);
|
|
||||||
Assert.False(result.PayloadTruncated);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ErrorDetail_AndExtra_Truncated_Independently()
|
|
||||||
{
|
|
||||||
// Each field is capped on its own — a 10 KB RequestSummary and a 10 KB
|
|
||||||
// ErrorDetail on the same Delivered row should both be cut to 8 KB and
|
|
||||||
// the row flagged truncated.
|
|
||||||
var input = new string('g', 10 * 1024);
|
|
||||||
var evt = NewEvent(
|
|
||||||
AuditStatus.Delivered,
|
|
||||||
request: input,
|
|
||||||
response: input,
|
|
||||||
errorDetail: input,
|
|
||||||
extra: input);
|
|
||||||
|
|
||||||
var result = Filter().Apply(evt);
|
|
||||||
|
|
||||||
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.RequestSummary!));
|
|
||||||
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.ResponseSummary!));
|
|
||||||
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.ErrorDetail!));
|
|
||||||
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.Extra!));
|
|
||||||
Assert.True(result.PayloadTruncated);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// IOptionsMonitor test double — returns the same snapshot on every read,
|
|
||||||
/// no change-token plumbing required for these tests (Bundle D wires the
|
|
||||||
/// real hot-reload path).
|
|
||||||
/// </summary>
|
|
||||||
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
|
|
||||||
{
|
|
||||||
private readonly AuditLogOptions _value;
|
|
||||||
|
|
||||||
public StaticMonitor(AuditLogOptions value) => _value = value;
|
|
||||||
|
|
||||||
public AuditLogOptions CurrentValue => _value;
|
|
||||||
|
|
||||||
public AuditLogOptions Get(string? name) => _value;
|
|
||||||
|
|
||||||
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
@@ -15,17 +17,13 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class FallbackAuditWriterTests
|
public class FallbackAuditWriterTests
|
||||||
{
|
{
|
||||||
private static AuditEvent NewEvent(string? target = null) => new()
|
private static AuditEvent NewEvent(string? target = null) => ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: Guid.NewGuid(),
|
||||||
EventId = Guid.NewGuid(),
|
occurredAtUtc: DateTime.UtcNow,
|
||||||
OccurredAtUtc = DateTime.UtcNow,
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.ApiCall,
|
||||||
Kind = AuditKind.ApiCall,
|
status: AuditStatus.Delivered,
|
||||||
Status = AuditStatus.Delivered,
|
target: target);
|
||||||
Target = target,
|
|
||||||
PayloadTruncated = false,
|
|
||||||
ForwardState = AuditForwardState.Pending,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>Flip-switch primary writer mock.</summary>
|
/// <summary>Flip-switch primary writer mock.</summary>
|
||||||
private sealed class FlipSwitchPrimary : IAuditWriter
|
private sealed class FlipSwitchPrimary : IAuditWriter
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||||
@@ -13,17 +14,13 @@ public class RingBufferFallbackTests
|
|||||||
{
|
{
|
||||||
private static AuditEvent NewEvent(string? target = null)
|
private static AuditEvent NewEvent(string? target = null)
|
||||||
{
|
{
|
||||||
return new AuditEvent
|
return ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: Guid.NewGuid(),
|
||||||
EventId = Guid.NewGuid(),
|
occurredAtUtc: DateTime.UtcNow,
|
||||||
OccurredAtUtc = DateTime.UtcNow,
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.ApiCall,
|
||||||
Kind = AuditKind.ApiCall,
|
status: AuditStatus.Delivered,
|
||||||
Status = AuditStatus.Delivered,
|
target: target);
|
||||||
Target = target,
|
|
||||||
PayloadTruncated = false,
|
|
||||||
ForwardState = AuditForwardState.Pending,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
+8
-10
@@ -2,7 +2,8 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||||
@@ -44,15 +45,12 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
|
|||||||
new FakeNodeIdentityProvider());
|
new FakeNodeIdentityProvider());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AuditEvent NewEvent(DateTime? occurredAtUtc = null) => new()
|
private static AuditEvent NewEvent(DateTime? occurredAtUtc = null) => ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: Guid.NewGuid(),
|
||||||
EventId = Guid.NewGuid(),
|
occurredAtUtc: occurredAtUtc ?? DateTime.UtcNow,
|
||||||
OccurredAtUtc = occurredAtUtc ?? DateTime.UtcNow,
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.ApiCall,
|
||||||
Kind = AuditKind.ApiCall,
|
status: AuditStatus.Delivered);
|
||||||
Status = AuditStatus.Delivered,
|
|
||||||
PayloadTruncated = false,
|
|
||||||
};
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task EmptyDb_Returns_Zero_Null_AndZeroBytes()
|
public async Task EmptyDb_Returns_Zero_Null_AndZeroBytes()
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||||
@@ -237,21 +238,18 @@ public class SqliteAuditWriterSchemaTests
|
|||||||
// A WriteAsync binding $ExecutionId must now succeed and round-trip;
|
// A WriteAsync binding $ExecutionId must now succeed and round-trip;
|
||||||
// without the ALTER it would fail with "no such column: ExecutionId"
|
// without the ALTER it would fail with "no such column: ExecutionId"
|
||||||
// and — because audit writes are best-effort — silently drop the row.
|
// and — because audit writes are best-effort — silently drop the row.
|
||||||
var evt = new AuditEvent
|
var evt = ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: Guid.NewGuid(),
|
||||||
EventId = Guid.NewGuid(),
|
occurredAtUtc: DateTime.UtcNow,
|
||||||
OccurredAtUtc = DateTime.UtcNow,
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.ApiCall,
|
||||||
Kind = AuditKind.ApiCall,
|
status: AuditStatus.Delivered,
|
||||||
Status = AuditStatus.Delivered,
|
executionId: executionId);
|
||||||
PayloadTruncated = false,
|
|
||||||
ExecutionId = executionId,
|
|
||||||
};
|
|
||||||
await writer.WriteAsync(evt);
|
await writer.WriteAsync(evt);
|
||||||
|
|
||||||
var rows = await writer.ReadPendingAsync(limit: 10);
|
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||||
var row = Assert.Single(rows);
|
var row = Assert.Single(rows);
|
||||||
Assert.Equal(executionId, row.ExecutionId);
|
Assert.Equal(executionId, row.AsRow().ExecutionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idempotency: a second writer over the now-upgraded DB must not error
|
// Idempotency: a second writer over the now-upgraded DB must not error
|
||||||
@@ -343,23 +341,20 @@ public class SqliteAuditWriterSchemaTests
|
|||||||
// round-trip; without the ALTER it would fail with "no such column:
|
// round-trip; without the ALTER it would fail with "no such column:
|
||||||
// ParentExecutionId" and — because audit writes are best-effort —
|
// ParentExecutionId" and — because audit writes are best-effort —
|
||||||
// silently drop the row.
|
// silently drop the row.
|
||||||
var evt = new AuditEvent
|
var evt = ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: Guid.NewGuid(),
|
||||||
EventId = Guid.NewGuid(),
|
occurredAtUtc: DateTime.UtcNow,
|
||||||
OccurredAtUtc = DateTime.UtcNow,
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.ApiCall,
|
||||||
Kind = AuditKind.ApiCall,
|
status: AuditStatus.Delivered,
|
||||||
Status = AuditStatus.Delivered,
|
executionId: executionId,
|
||||||
PayloadTruncated = false,
|
parentExecutionId: parentExecutionId);
|
||||||
ExecutionId = executionId,
|
|
||||||
ParentExecutionId = parentExecutionId,
|
|
||||||
};
|
|
||||||
await writer.WriteAsync(evt);
|
await writer.WriteAsync(evt);
|
||||||
|
|
||||||
var rows = await writer.ReadPendingAsync(limit: 10);
|
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||||
var row = Assert.Single(rows);
|
var row = Assert.Single(rows);
|
||||||
Assert.Equal(executionId, row.ExecutionId);
|
Assert.Equal(executionId, row.AsRow().ExecutionId);
|
||||||
Assert.Equal(parentExecutionId, row.ParentExecutionId);
|
Assert.Equal(parentExecutionId, row.AsRow().ParentExecutionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idempotency: a second writer over the now-upgraded DB must not error
|
// Idempotency: a second writer over the now-upgraded DB must not error
|
||||||
@@ -376,21 +371,18 @@ public class SqliteAuditWriterSchemaTests
|
|||||||
var (writer, _) = CreateWriter(nameof(WriteAsync_NullParentExecutionId_RoundTripsAsNull));
|
var (writer, _) = CreateWriter(nameof(WriteAsync_NullParentExecutionId_RoundTripsAsNull));
|
||||||
await using (writer)
|
await using (writer)
|
||||||
{
|
{
|
||||||
var evt = new AuditEvent
|
var evt = ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: Guid.NewGuid(),
|
||||||
EventId = Guid.NewGuid(),
|
occurredAtUtc: DateTime.UtcNow,
|
||||||
OccurredAtUtc = DateTime.UtcNow,
|
channel: AuditChannel.Notification,
|
||||||
Channel = AuditChannel.Notification,
|
kind: AuditKind.NotifySend,
|
||||||
Kind = AuditKind.NotifySend,
|
status: AuditStatus.Submitted);
|
||||||
Status = AuditStatus.Submitted,
|
// ParentExecutionId left null (not a factory arg → defaults null)
|
||||||
PayloadTruncated = false,
|
|
||||||
// ParentExecutionId left null
|
|
||||||
};
|
|
||||||
await writer.WriteAsync(evt);
|
await writer.WriteAsync(evt);
|
||||||
|
|
||||||
var rows = await writer.ReadPendingAsync(limit: 10);
|
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||||
var row = Assert.Single(rows);
|
var row = Assert.Single(rows);
|
||||||
Assert.Null(row.ParentExecutionId);
|
Assert.Null(row.AsRow().ParentExecutionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,16 +465,13 @@ public class SqliteAuditWriterSchemaTests
|
|||||||
// A WriteAsync binding $SourceNode must now succeed and round-trip;
|
// A WriteAsync binding $SourceNode must now succeed and round-trip;
|
||||||
// without the ALTER it would fail with "no such column: SourceNode"
|
// without the ALTER it would fail with "no such column: SourceNode"
|
||||||
// and — because audit writes are best-effort — silently drop the row.
|
// and — because audit writes are best-effort — silently drop the row.
|
||||||
var evt = new AuditEvent
|
var evt = ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: Guid.NewGuid(),
|
||||||
EventId = Guid.NewGuid(),
|
occurredAtUtc: DateTime.UtcNow,
|
||||||
OccurredAtUtc = DateTime.UtcNow,
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.ApiCall,
|
||||||
Kind = AuditKind.ApiCall,
|
status: AuditStatus.Delivered,
|
||||||
Status = AuditStatus.Delivered,
|
sourceNode: "node-a");
|
||||||
PayloadTruncated = false,
|
|
||||||
SourceNode = "node-a",
|
|
||||||
};
|
|
||||||
await writer.WriteAsync(evt);
|
await writer.WriteAsync(evt);
|
||||||
|
|
||||||
var rows = await writer.ReadPendingAsync(limit: 10);
|
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||||
@@ -504,16 +493,13 @@ public class SqliteAuditWriterSchemaTests
|
|||||||
var (writer, _) = CreateWriter(nameof(WriteAsync_persists_SourceNode_field));
|
var (writer, _) = CreateWriter(nameof(WriteAsync_persists_SourceNode_field));
|
||||||
await using (writer)
|
await using (writer)
|
||||||
{
|
{
|
||||||
var evt = new AuditEvent
|
var evt = ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: Guid.NewGuid(),
|
||||||
EventId = Guid.NewGuid(),
|
occurredAtUtc: DateTime.UtcNow,
|
||||||
OccurredAtUtc = DateTime.UtcNow,
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.ApiCall,
|
||||||
Kind = AuditKind.ApiCall,
|
status: AuditStatus.Delivered,
|
||||||
Status = AuditStatus.Delivered,
|
sourceNode: "node-a");
|
||||||
PayloadTruncated = false,
|
|
||||||
SourceNode = "node-a",
|
|
||||||
};
|
|
||||||
await writer.WriteAsync(evt);
|
await writer.WriteAsync(evt);
|
||||||
|
|
||||||
var rows = await writer.ReadPendingAsync(limit: 10);
|
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||||
@@ -528,16 +514,13 @@ public class SqliteAuditWriterSchemaTests
|
|||||||
var (writer, _) = CreateWriter(nameof(WriteAsync_persists_null_SourceNode));
|
var (writer, _) = CreateWriter(nameof(WriteAsync_persists_null_SourceNode));
|
||||||
await using (writer)
|
await using (writer)
|
||||||
{
|
{
|
||||||
var evt = new AuditEvent
|
var evt = ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: Guid.NewGuid(),
|
||||||
EventId = Guid.NewGuid(),
|
occurredAtUtc: DateTime.UtcNow,
|
||||||
OccurredAtUtc = DateTime.UtcNow,
|
channel: AuditChannel.Notification,
|
||||||
Channel = AuditChannel.Notification,
|
kind: AuditKind.NotifySend,
|
||||||
Kind = AuditKind.NotifySend,
|
status: AuditStatus.Submitted);
|
||||||
Status = AuditStatus.Submitted,
|
// SourceNode left null (not a factory arg → defaults null)
|
||||||
PayloadTruncated = false,
|
|
||||||
// SourceNode left null
|
|
||||||
};
|
|
||||||
await writer.WriteAsync(evt);
|
await writer.WriteAsync(evt);
|
||||||
|
|
||||||
var rows = await writer.ReadPendingAsync(limit: 10);
|
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||||
@@ -51,18 +52,23 @@ public class SqliteAuditWriterWriteTests
|
|||||||
return connection;
|
return connection;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AuditEvent NewEvent(Guid? id = null, DateTime? occurredAtUtc = null)
|
// C3 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared
|
||||||
{
|
// factory. The SQLite writer's transitional shim decomposes it into the 24 columns
|
||||||
return new AuditEvent
|
// (defaulting ForwardState=Pending) on INSERT and recomposes the canonical record
|
||||||
{
|
// on read. ExecutionId/SourceNode ride through DetailsJson / the top-level field.
|
||||||
EventId = id ?? Guid.NewGuid(),
|
private static AuditEvent NewEvent(
|
||||||
OccurredAtUtc = occurredAtUtc ?? DateTime.UtcNow,
|
Guid? id = null,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
DateTime? occurredAtUtc = null,
|
||||||
Kind = AuditKind.ApiCall,
|
Guid? executionId = null,
|
||||||
Status = AuditStatus.Delivered,
|
string? sourceNode = null)
|
||||||
PayloadTruncated = false,
|
=> ScadaBridgeAuditEventFactory.Create(
|
||||||
};
|
channel: AuditChannel.ApiOutbound,
|
||||||
}
|
kind: AuditKind.ApiCall,
|
||||||
|
status: AuditStatus.Delivered,
|
||||||
|
eventId: id ?? Guid.NewGuid(),
|
||||||
|
occurredAtUtc: occurredAtUtc ?? DateTime.UtcNow,
|
||||||
|
executionId: executionId,
|
||||||
|
sourceNode: sourceNode);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_FreshEvent_PersistsWithForwardStatePending()
|
public async Task WriteAsync_FreshEvent_PersistsWithForwardStatePending()
|
||||||
@@ -134,7 +140,10 @@ public class SqliteAuditWriterWriteTests
|
|||||||
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_ForcesForwardStatePending_IfNull));
|
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_ForcesForwardStatePending_IfNull));
|
||||||
await using var _ = writer;
|
await using var _ = writer;
|
||||||
|
|
||||||
var evt = NewEvent() with { ForwardState = null };
|
// C3 (Task 2.5): ForwardState is no longer a field on the canonical record;
|
||||||
|
// a fresh canonical event carries none, and the SQLite shim defaults it to
|
||||||
|
// Pending on INSERT — exactly the behaviour this test pins.
|
||||||
|
var evt = NewEvent();
|
||||||
await writer.WriteAsync(evt);
|
await writer.WriteAsync(evt);
|
||||||
|
|
||||||
using var connection = OpenVerifierConnection(dataSource);
|
using var connection = OpenVerifierConnection(dataSource);
|
||||||
@@ -372,13 +381,13 @@ public class SqliteAuditWriterWriteTests
|
|||||||
await using var _w = writer;
|
await using var _w = writer;
|
||||||
|
|
||||||
var executionId = Guid.NewGuid();
|
var executionId = Guid.NewGuid();
|
||||||
var evt = NewEvent() with { ExecutionId = executionId };
|
var evt = NewEvent(executionId: executionId);
|
||||||
await writer.WriteAsync(evt);
|
await writer.WriteAsync(evt);
|
||||||
|
|
||||||
var rows = await writer.ReadPendingAsync(limit: 10);
|
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||||
|
|
||||||
var row = Assert.Single(rows);
|
var row = Assert.Single(rows);
|
||||||
Assert.Equal(executionId, row.ExecutionId);
|
Assert.Equal(executionId, row.AsRow().ExecutionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -387,13 +396,13 @@ public class SqliteAuditWriterWriteTests
|
|||||||
var (writer, _) = CreateWriter(nameof(WriteAsync_NullExecutionId_RoundTripsAsNull));
|
var (writer, _) = CreateWriter(nameof(WriteAsync_NullExecutionId_RoundTripsAsNull));
|
||||||
await using var _w = writer;
|
await using var _w = writer;
|
||||||
|
|
||||||
var evt = NewEvent() with { ExecutionId = null };
|
var evt = NewEvent(); // executionId defaults to null
|
||||||
await writer.WriteAsync(evt);
|
await writer.WriteAsync(evt);
|
||||||
|
|
||||||
var rows = await writer.ReadPendingAsync(limit: 10);
|
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||||
|
|
||||||
var row = Assert.Single(rows);
|
var row = Assert.Single(rows);
|
||||||
Assert.Null(row.ExecutionId);
|
Assert.Null(row.AsRow().ExecutionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- SourceNode stamping (Tasks 11/12) ----- //
|
// ----- SourceNode stamping (Tasks 11/12) ----- //
|
||||||
@@ -425,7 +434,7 @@ public class SqliteAuditWriterWriteTests
|
|||||||
|
|
||||||
// Reconciled rows from another node arrive with their origin's
|
// Reconciled rows from another node arrive with their origin's
|
||||||
// SourceNode already populated; the writer must preserve it.
|
// SourceNode already populated; the writer must preserve it.
|
||||||
var evt = NewEvent() with { SourceNode = "node-z" };
|
var evt = NewEvent(sourceNode: "node-z");
|
||||||
await writer.WriteAsync(evt);
|
await writer.WriteAsync(evt);
|
||||||
|
|
||||||
var rows = await writer.ReadPendingAsync(limit: 10);
|
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||||
|
|||||||
+43
-42
@@ -3,6 +3,7 @@ using NSubstitute;
|
|||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
@@ -67,11 +68,11 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
httpStatus: 503));
|
httpStatus: 503));
|
||||||
|
|
||||||
var packet = Assert.Single(captured);
|
var packet = Assert.Single(captured);
|
||||||
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
|
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.AsRow().Kind);
|
||||||
Assert.Equal(AuditStatus.Attempted, packet.Audit.Status);
|
Assert.Equal(AuditStatus.Attempted, packet.Audit.AsRow().Status);
|
||||||
Assert.Equal(503, packet.Audit.HttpStatus);
|
Assert.Equal(503, packet.Audit.AsRow().HttpStatus);
|
||||||
Assert.Equal("HTTP 503", packet.Audit.ErrorMessage);
|
Assert.Equal("HTTP 503", packet.Audit.AsRow().ErrorMessage);
|
||||||
Assert.Equal(_id.Value, packet.Audit.CorrelationId);
|
Assert.Equal(_id.Value, packet.Audit.AsRow().CorrelationId);
|
||||||
Assert.Equal("Attempted", packet.Operational.Status);
|
Assert.Equal("Attempted", packet.Operational.Status);
|
||||||
Assert.Equal(2, packet.Operational.RetryCount);
|
Assert.Equal(2, packet.Operational.RetryCount);
|
||||||
Assert.Null(packet.Operational.TerminalAtUtc);
|
Assert.Null(packet.Operational.TerminalAtUtc);
|
||||||
@@ -90,17 +91,17 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
Assert.Equal(2, captured.Count);
|
Assert.Equal(2, captured.Count);
|
||||||
|
|
||||||
var attempted = captured[0];
|
var attempted = captured[0];
|
||||||
Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.Kind);
|
Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.AsRow().Kind);
|
||||||
Assert.Equal(AuditStatus.Attempted, attempted.Audit.Status);
|
Assert.Equal(AuditStatus.Attempted, attempted.Audit.AsRow().Status);
|
||||||
Assert.Equal("Attempted", attempted.Operational.Status);
|
Assert.Equal("Attempted", attempted.Operational.Status);
|
||||||
Assert.Null(attempted.Operational.TerminalAtUtc);
|
Assert.Null(attempted.Operational.TerminalAtUtc);
|
||||||
|
|
||||||
var resolve = captured[1];
|
var resolve = captured[1];
|
||||||
Assert.Equal(AuditKind.CachedResolve, resolve.Audit.Kind);
|
Assert.Equal(AuditKind.CachedResolve, resolve.Audit.AsRow().Kind);
|
||||||
Assert.Equal(AuditStatus.Delivered, resolve.Audit.Status);
|
Assert.Equal(AuditStatus.Delivered, resolve.Audit.AsRow().Status);
|
||||||
Assert.Equal("Delivered", resolve.Operational.Status);
|
Assert.Equal("Delivered", resolve.Operational.Status);
|
||||||
Assert.NotNull(resolve.Operational.TerminalAtUtc);
|
Assert.NotNull(resolve.Operational.TerminalAtUtc);
|
||||||
Assert.Equal(_id.Value, resolve.Audit.CorrelationId);
|
Assert.Equal(_id.Value, resolve.Audit.AsRow().CorrelationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -116,9 +117,9 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
lastError: "Permanent failure (handler returned false)"));
|
lastError: "Permanent failure (handler returned false)"));
|
||||||
|
|
||||||
Assert.Equal(2, captured.Count);
|
Assert.Equal(2, captured.Count);
|
||||||
Assert.Equal(AuditKind.ApiCallCached, captured[0].Audit.Kind);
|
Assert.Equal(AuditKind.ApiCallCached, captured[0].Audit.AsRow().Kind);
|
||||||
Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.Kind);
|
Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.AsRow().Kind);
|
||||||
Assert.Equal(AuditStatus.Parked, captured[1].Audit.Status);
|
Assert.Equal(AuditStatus.Parked, captured[1].Audit.AsRow().Status);
|
||||||
Assert.Equal("Parked", captured[1].Operational.Status);
|
Assert.Equal("Parked", captured[1].Operational.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,8 +134,8 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.ParkedMaxRetries));
|
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.ParkedMaxRetries));
|
||||||
|
|
||||||
Assert.Equal(2, captured.Count);
|
Assert.Equal(2, captured.Count);
|
||||||
Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.Kind);
|
Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.AsRow().Kind);
|
||||||
Assert.Equal(AuditStatus.Parked, captured[1].Audit.Status);
|
Assert.Equal(AuditStatus.Parked, captured[1].Audit.AsRow().Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -149,11 +150,11 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
CachedCallAttemptOutcome.Delivered, channel: "DbOutbound"));
|
CachedCallAttemptOutcome.Delivered, channel: "DbOutbound"));
|
||||||
|
|
||||||
Assert.Equal(2, captured.Count);
|
Assert.Equal(2, captured.Count);
|
||||||
Assert.Equal(AuditKind.DbWriteCached, captured[0].Audit.Kind);
|
Assert.Equal(AuditKind.DbWriteCached, captured[0].Audit.AsRow().Kind);
|
||||||
Assert.Equal(AuditChannel.DbOutbound, captured[0].Audit.Channel);
|
Assert.Equal(AuditChannel.DbOutbound, captured[0].Audit.AsRow().Channel);
|
||||||
Assert.Equal("DbOutbound", captured[0].Operational.Channel);
|
Assert.Equal("DbOutbound", captured[0].Operational.Channel);
|
||||||
Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.Kind);
|
Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.AsRow().Kind);
|
||||||
Assert.Equal(AuditChannel.DbOutbound, captured[1].Audit.Channel);
|
Assert.Equal(AuditChannel.DbOutbound, captured[1].Audit.AsRow().Channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -184,11 +185,11 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
httpStatus: 500));
|
httpStatus: 500));
|
||||||
|
|
||||||
Assert.NotNull(captured);
|
Assert.NotNull(captured);
|
||||||
Assert.Equal("site-77", captured!.Audit.SourceSiteId);
|
Assert.Equal("site-77", captured!.Audit.AsRow().SourceSiteId);
|
||||||
Assert.Equal("Plant.Pump42", captured.Audit.SourceInstanceId);
|
Assert.Equal("Plant.Pump42", captured.Audit.AsRow().SourceInstanceId);
|
||||||
Assert.Equal("ERP.GetOrder", captured.Audit.Target);
|
Assert.Equal("ERP.GetOrder", captured.Audit.AsRow().Target);
|
||||||
Assert.Equal(42, captured.Audit.DurationMs);
|
Assert.Equal(42, captured.Audit.AsRow().DurationMs);
|
||||||
Assert.Equal(_id.Value, captured.Audit.CorrelationId);
|
Assert.Equal(_id.Value, captured.Audit.AsRow().CorrelationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Audit Log #23 (ExecutionId Task 4): ExecutionId / SourceScript ──
|
// ── Audit Log #23 (ExecutionId Task 4): ExecutionId / SourceScript ──
|
||||||
@@ -212,9 +213,9 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
sourceScript: "Plant.Pump42/OnTick"));
|
sourceScript: "Plant.Pump42/OnTick"));
|
||||||
|
|
||||||
var packet = Assert.Single(captured);
|
var packet = Assert.Single(captured);
|
||||||
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
|
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.AsRow().Kind);
|
||||||
Assert.Equal(executionId, packet.Audit.ExecutionId);
|
Assert.Equal(executionId, packet.Audit.AsRow().ExecutionId);
|
||||||
Assert.Equal("Plant.Pump42/OnTick", packet.Audit.SourceScript);
|
Assert.Equal("Plant.Pump42/OnTick", packet.Audit.AsRow().SourceScript);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -235,13 +236,13 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
sourceScript: "Plant.Tank/OnAlarm"));
|
sourceScript: "Plant.Tank/OnAlarm"));
|
||||||
|
|
||||||
Assert.Equal(2, captured.Count);
|
Assert.Equal(2, captured.Count);
|
||||||
var resolve = Assert.Single(captured, p => p.Audit.Kind == AuditKind.CachedResolve);
|
var resolve = Assert.Single(captured, p => p.Audit.AsRow().Kind == AuditKind.CachedResolve);
|
||||||
Assert.Equal(executionId, resolve.Audit.ExecutionId);
|
Assert.Equal(executionId, resolve.Audit.AsRow().ExecutionId);
|
||||||
Assert.Equal("Plant.Tank/OnAlarm", resolve.Audit.SourceScript);
|
Assert.Equal("Plant.Tank/OnAlarm", resolve.Audit.AsRow().SourceScript);
|
||||||
|
|
||||||
var attempted = Assert.Single(captured, p => p.Audit.Kind == AuditKind.DbWriteCached);
|
var attempted = Assert.Single(captured, p => p.Audit.AsRow().Kind == AuditKind.DbWriteCached);
|
||||||
Assert.Equal(executionId, attempted.Audit.ExecutionId);
|
Assert.Equal(executionId, attempted.Audit.AsRow().ExecutionId);
|
||||||
Assert.Equal("Plant.Tank/OnAlarm", attempted.Audit.SourceScript);
|
Assert.Equal("Plant.Tank/OnAlarm", attempted.Audit.AsRow().SourceScript);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -258,8 +259,8 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure));
|
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure));
|
||||||
|
|
||||||
Assert.NotNull(captured);
|
Assert.NotNull(captured);
|
||||||
Assert.Null(captured!.Audit.ExecutionId);
|
Assert.Null(captured!.Audit.AsRow().ExecutionId);
|
||||||
Assert.Null(captured.Audit.SourceScript);
|
Assert.Null(captured.Audit.AsRow().SourceScript);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Audit Log #23 (ParentExecutionId Task 6): ParentExecutionId ──
|
// ── Audit Log #23 (ParentExecutionId Task 6): ParentExecutionId ──
|
||||||
@@ -282,8 +283,8 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
parentExecutionId: parentExecutionId));
|
parentExecutionId: parentExecutionId));
|
||||||
|
|
||||||
var packet = Assert.Single(captured);
|
var packet = Assert.Single(captured);
|
||||||
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
|
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.AsRow().Kind);
|
||||||
Assert.Equal(parentExecutionId, packet.Audit.ParentExecutionId);
|
Assert.Equal(parentExecutionId, packet.Audit.AsRow().ParentExecutionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -304,11 +305,11 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
parentExecutionId: parentExecutionId));
|
parentExecutionId: parentExecutionId));
|
||||||
|
|
||||||
Assert.Equal(2, captured.Count);
|
Assert.Equal(2, captured.Count);
|
||||||
var resolve = Assert.Single(captured, p => p.Audit.Kind == AuditKind.CachedResolve);
|
var resolve = Assert.Single(captured, p => p.Audit.AsRow().Kind == AuditKind.CachedResolve);
|
||||||
Assert.Equal(parentExecutionId, resolve.Audit.ParentExecutionId);
|
Assert.Equal(parentExecutionId, resolve.Audit.AsRow().ParentExecutionId);
|
||||||
|
|
||||||
var attempted = Assert.Single(captured, p => p.Audit.Kind == AuditKind.DbWriteCached);
|
var attempted = Assert.Single(captured, p => p.Audit.AsRow().Kind == AuditKind.DbWriteCached);
|
||||||
Assert.Equal(parentExecutionId, attempted.Audit.ParentExecutionId);
|
Assert.Equal(parentExecutionId, attempted.Audit.AsRow().ParentExecutionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -325,7 +326,7 @@ public class CachedCallLifecycleBridgeTests
|
|||||||
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure));
|
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure));
|
||||||
|
|
||||||
Assert.NotNull(captured);
|
Assert.NotNull(captured);
|
||||||
Assert.Null(captured!.Audit.ParentExecutionId);
|
Assert.Null(captured!.Audit.AsRow().ParentExecutionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SourceNode-stamping (Task 14) ──
|
// ── SourceNode-stamping (Task 14) ──
|
||||||
|
|||||||
+40
-47
@@ -2,7 +2,9 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using NSubstitute.ExceptionExtensions;
|
using NSubstitute.ExceptionExtensions;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||||
@@ -32,20 +34,17 @@ public class CachedCallTelemetryForwarderTests
|
|||||||
|
|
||||||
private CachedCallTelemetry SubmitPacket() =>
|
private CachedCallTelemetry SubmitPacket() =>
|
||||||
new(
|
new(
|
||||||
Audit: new AuditEvent
|
Audit: ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: Guid.NewGuid(),
|
||||||
EventId = Guid.NewGuid(),
|
occurredAtUtc: _now,
|
||||||
OccurredAtUtc = _now,
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.CachedSubmit,
|
||||||
Kind = AuditKind.CachedSubmit,
|
correlationId: _id.Value,
|
||||||
CorrelationId = _id.Value,
|
sourceSiteId: "site-1",
|
||||||
SourceSiteId = "site-1",
|
sourceInstanceId: "inst-1",
|
||||||
SourceInstanceId = "inst-1",
|
sourceScript: "ScriptActor:doStuff",
|
||||||
SourceScript = "ScriptActor:doStuff",
|
target: "ERP.GetOrder",
|
||||||
Target = "ERP.GetOrder",
|
status: AuditStatus.Submitted),
|
||||||
Status = AuditStatus.Submitted,
|
|
||||||
ForwardState = AuditForwardState.Pending,
|
|
||||||
},
|
|
||||||
Operational: new SiteCallOperational(
|
Operational: new SiteCallOperational(
|
||||||
TrackedOperationId: _id,
|
TrackedOperationId: _id,
|
||||||
Channel: "ApiOutbound",
|
Channel: "ApiOutbound",
|
||||||
@@ -62,20 +61,17 @@ public class CachedCallTelemetryForwarderTests
|
|||||||
|
|
||||||
private CachedCallTelemetry AttemptedPacket(int retryCount = 1, string? lastError = "HTTP 500", int? httpStatus = 500) =>
|
private CachedCallTelemetry AttemptedPacket(int retryCount = 1, string? lastError = "HTTP 500", int? httpStatus = 500) =>
|
||||||
new(
|
new(
|
||||||
Audit: new AuditEvent
|
Audit: ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: Guid.NewGuid(),
|
||||||
EventId = Guid.NewGuid(),
|
occurredAtUtc: _now,
|
||||||
OccurredAtUtc = _now,
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.ApiCallCached,
|
||||||
Kind = AuditKind.ApiCallCached,
|
correlationId: _id.Value,
|
||||||
CorrelationId = _id.Value,
|
sourceSiteId: "site-1",
|
||||||
SourceSiteId = "site-1",
|
target: "ERP.GetOrder",
|
||||||
Target = "ERP.GetOrder",
|
status: AuditStatus.Attempted,
|
||||||
Status = AuditStatus.Attempted,
|
httpStatus: httpStatus,
|
||||||
HttpStatus = httpStatus,
|
errorMessage: lastError),
|
||||||
ErrorMessage = lastError,
|
|
||||||
ForwardState = AuditForwardState.Pending,
|
|
||||||
},
|
|
||||||
Operational: new SiteCallOperational(
|
Operational: new SiteCallOperational(
|
||||||
TrackedOperationId: _id,
|
TrackedOperationId: _id,
|
||||||
Channel: "ApiOutbound",
|
Channel: "ApiOutbound",
|
||||||
@@ -92,18 +88,15 @@ public class CachedCallTelemetryForwarderTests
|
|||||||
|
|
||||||
private CachedCallTelemetry ResolvePacket(string status = "Delivered") =>
|
private CachedCallTelemetry ResolvePacket(string status = "Delivered") =>
|
||||||
new(
|
new(
|
||||||
Audit: new AuditEvent
|
Audit: ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: Guid.NewGuid(),
|
||||||
EventId = Guid.NewGuid(),
|
occurredAtUtc: _now,
|
||||||
OccurredAtUtc = _now,
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.CachedResolve,
|
||||||
Kind = AuditKind.CachedResolve,
|
correlationId: _id.Value,
|
||||||
CorrelationId = _id.Value,
|
sourceSiteId: "site-1",
|
||||||
SourceSiteId = "site-1",
|
target: "ERP.GetOrder",
|
||||||
Target = "ERP.GetOrder",
|
status: Enum.Parse<AuditStatus>(status)),
|
||||||
Status = Enum.Parse<AuditStatus>(status),
|
|
||||||
ForwardState = AuditForwardState.Pending,
|
|
||||||
},
|
|
||||||
Operational: new SiteCallOperational(
|
Operational: new SiteCallOperational(
|
||||||
TrackedOperationId: _id,
|
TrackedOperationId: _id,
|
||||||
Channel: "ApiOutbound",
|
Channel: "ApiOutbound",
|
||||||
@@ -130,8 +123,8 @@ public class CachedCallTelemetryForwarderTests
|
|||||||
await _writer.Received(1).WriteAsync(
|
await _writer.Received(1).WriteAsync(
|
||||||
Arg.Is<AuditEvent>(e =>
|
Arg.Is<AuditEvent>(e =>
|
||||||
e.EventId == packet.Audit.EventId
|
e.EventId == packet.Audit.EventId
|
||||||
&& e.Kind == AuditKind.CachedSubmit
|
&& e.AsRow().Kind == AuditKind.CachedSubmit
|
||||||
&& e.Status == AuditStatus.Submitted),
|
&& e.AsRow().Status == AuditStatus.Submitted),
|
||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
|
|
||||||
// Tracking row: insert-if-not-exists with kind discriminator.
|
// Tracking row: insert-if-not-exists with kind discriminator.
|
||||||
@@ -165,8 +158,8 @@ public class CachedCallTelemetryForwarderTests
|
|||||||
await _writer.Received(1).WriteAsync(
|
await _writer.Received(1).WriteAsync(
|
||||||
Arg.Is<AuditEvent>(e =>
|
Arg.Is<AuditEvent>(e =>
|
||||||
e.EventId == packet.Audit.EventId
|
e.EventId == packet.Audit.EventId
|
||||||
&& e.Kind == AuditKind.ApiCallCached
|
&& e.AsRow().Kind == AuditKind.ApiCallCached
|
||||||
&& e.Status == AuditStatus.Attempted),
|
&& e.AsRow().Status == AuditStatus.Attempted),
|
||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
|
|
||||||
await _tracking.Received(1).RecordAttemptAsync(
|
await _tracking.Received(1).RecordAttemptAsync(
|
||||||
@@ -188,8 +181,8 @@ public class CachedCallTelemetryForwarderTests
|
|||||||
await _writer.Received(1).WriteAsync(
|
await _writer.Received(1).WriteAsync(
|
||||||
Arg.Is<AuditEvent>(e =>
|
Arg.Is<AuditEvent>(e =>
|
||||||
e.EventId == packet.Audit.EventId
|
e.EventId == packet.Audit.EventId
|
||||||
&& e.Kind == AuditKind.CachedResolve
|
&& e.AsRow().Kind == AuditKind.CachedResolve
|
||||||
&& e.Status == AuditStatus.Delivered),
|
&& e.AsRow().Status == AuditStatus.Delivered),
|
||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
|
|
||||||
await _tracking.Received(1).RecordTerminalAsync(
|
await _tracking.Received(1).RecordTerminalAsync(
|
||||||
|
|||||||
+9
-11
@@ -2,7 +2,8 @@ using Akka.Actor;
|
|||||||
using Akka.TestKit.Xunit2;
|
using Akka.TestKit.Xunit2;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||||
@@ -29,16 +30,13 @@ public class ClusterClientSiteAuditClientTests : TestKit
|
|||||||
/// <summary>Short Ask timeout so the timeout test completes quickly.</summary>
|
/// <summary>Short Ask timeout so the timeout test completes quickly.</summary>
|
||||||
private static readonly TimeSpan AskTimeout = TimeSpan.FromMilliseconds(500);
|
private static readonly TimeSpan AskTimeout = TimeSpan.FromMilliseconds(500);
|
||||||
|
|
||||||
private static AuditEvent NewEvent(Guid? id = null) => new()
|
private static AuditEvent NewEvent(Guid? id = null) => ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: id ?? Guid.NewGuid(),
|
||||||
EventId = id ?? Guid.NewGuid(),
|
occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||||
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.ApiCall,
|
||||||
Kind = AuditKind.ApiCall,
|
status: AuditStatus.Delivered,
|
||||||
Status = AuditStatus.Delivered,
|
sourceSiteId: "site-1");
|
||||||
SourceSiteId = "site-1",
|
|
||||||
ForwardState = AuditForwardState.Pending,
|
|
||||||
};
|
|
||||||
|
|
||||||
private static AuditEventBatch BatchOf(IEnumerable<AuditEvent> events)
|
private static AuditEventBatch BatchOf(IEnumerable<AuditEvent> events)
|
||||||
{
|
{
|
||||||
|
|||||||
+21
-23
@@ -6,7 +6,8 @@ using Microsoft.Extensions.Options;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using NSubstitute.ExceptionExtensions;
|
using NSubstitute.ExceptionExtensions;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
@@ -66,16 +67,13 @@ public class SiteAuditTelemetryActorTests : TestKit
|
|||||||
NullLogger<SiteAuditTelemetryActor>.Instance,
|
NullLogger<SiteAuditTelemetryActor>.Instance,
|
||||||
(IOperationTrackingStore?)_trackingStore)));
|
(IOperationTrackingStore?)_trackingStore)));
|
||||||
|
|
||||||
private static AuditEvent NewEvent(Guid? id = null) => new()
|
private static AuditEvent NewEvent(Guid? id = null) => ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
eventId: id ?? Guid.NewGuid(),
|
||||||
EventId = id ?? Guid.NewGuid(),
|
occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||||
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.ApiCall,
|
||||||
Kind = AuditKind.ApiCall,
|
status: AuditStatus.Delivered,
|
||||||
Status = AuditStatus.Delivered,
|
sourceSiteId: "site-1");
|
||||||
SourceSiteId = "site-1",
|
|
||||||
ForwardState = AuditForwardState.Pending,
|
|
||||||
};
|
|
||||||
|
|
||||||
private static IngestAck AckAll(IReadOnlyList<AuditEvent> events)
|
private static IngestAck AckAll(IReadOnlyList<AuditEvent> events)
|
||||||
{
|
{
|
||||||
@@ -265,18 +263,18 @@ public class SiteAuditTelemetryActorTests : TestKit
|
|||||||
AuditKind kind = AuditKind.CachedSubmit,
|
AuditKind kind = AuditKind.CachedSubmit,
|
||||||
Guid? eventId = null,
|
Guid? eventId = null,
|
||||||
Guid? correlationId = null,
|
Guid? correlationId = null,
|
||||||
string sourceSiteId = "site-1") => new()
|
string sourceSiteId = "site-1") =>
|
||||||
{
|
// C3 (Task 2.5): canonical record via the shared factory; ForwardState is
|
||||||
EventId = eventId ?? Guid.NewGuid(),
|
// no longer a record field (the SQLite shim defaults it on INSERT).
|
||||||
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
ScadaBridgeAuditEventFactory.Create(
|
||||||
Channel = AuditChannel.ApiOutbound,
|
channel: AuditChannel.ApiOutbound,
|
||||||
Kind = kind,
|
kind: kind,
|
||||||
Status = AuditStatus.Submitted,
|
status: AuditStatus.Submitted,
|
||||||
SourceSiteId = sourceSiteId,
|
eventId: eventId ?? Guid.NewGuid(),
|
||||||
Target = "ERP.GetOrder",
|
occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||||
CorrelationId = correlationId ?? Guid.NewGuid(),
|
target: "ERP.GetOrder",
|
||||||
ForwardState = AuditForwardState.Pending,
|
sourceSiteId: sourceSiteId,
|
||||||
};
|
correlationId: correlationId ?? Guid.NewGuid());
|
||||||
|
|
||||||
private static TrackingStatusSnapshot NewSnapshot(
|
private static TrackingStatusSnapshot NewSnapshot(
|
||||||
TrackedOperationId id,
|
TrackedOperationId id,
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ using Microsoft.Extensions.Hosting;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Audit;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
@@ -38,17 +38,17 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Audit;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuditExportEndpointsTests
|
public class AuditExportEndpointsTests
|
||||||
{
|
{
|
||||||
private static AuditEvent SampleEvent() => new()
|
// C3 (Task 2.5): the export endpoint reads canonical ZB.MOM.WW.Audit.AuditEvent
|
||||||
{
|
// rows straight off IAuditLogRepository.QueryAsync; build them via the factory.
|
||||||
EventId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
private static AuditEvent SampleEvent() =>
|
||||||
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
ScadaBridgeAuditEventFactory.Create(
|
||||||
IngestedAtUtc = null,
|
channel: AuditChannel.ApiOutbound,
|
||||||
Channel = AuditChannel.ApiOutbound,
|
kind: AuditKind.ApiCall,
|
||||||
Kind = AuditKind.ApiCall,
|
status: AuditStatus.Delivered,
|
||||||
SourceSiteId = "plant-a",
|
eventId: Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||||
Status = AuditStatus.Delivered,
|
occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
||||||
HttpStatus = 200,
|
sourceSiteId: "plant-a",
|
||||||
};
|
httpStatus: 200);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a tiny in-process test host that wires the export endpoint to a
|
/// Builds a tiny in-process test host that wires the export endpoint to a
|
||||||
|
|||||||
+3
-3
@@ -3,7 +3,7 @@ using Bunit.TestDoubles;
|
|||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
|
||||||
@@ -13,7 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
|
|||||||
///
|
///
|
||||||
/// The drawer is a child component opened from the Audit Log page when a grid row
|
/// The drawer is a child component opened from the Audit Log page when a grid row
|
||||||
/// is clicked. It renders the offcanvas chrome (header, open/close) and delegates
|
/// is clicked. It renders the offcanvas chrome (header, open/close) and delegates
|
||||||
/// the <see cref="AuditEvent"/> body to the shared <see cref="AuditEventDetail"/>
|
/// the <see cref="AuditEventView"/> body to the shared <see cref="AuditEventDetail"/>
|
||||||
/// component, which since the recent refactor owns the channel-aware bodies
|
/// component, which since the recent refactor owns the channel-aware bodies
|
||||||
/// (JSON pretty-print, SQL block for DbOutbound), redaction badges on
|
/// (JSON pretty-print, SQL block for DbOutbound), redaction badges on
|
||||||
/// Request/Response, and conditional action buttons.
|
/// Request/Response, and conditional action buttons.
|
||||||
@@ -32,7 +32,7 @@ public class AuditDrilldownDrawerTests : BunitContext
|
|||||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AuditEvent MakeEvent(
|
private static AuditEventView MakeEvent(
|
||||||
AuditChannel channel = AuditChannel.ApiOutbound,
|
AuditChannel channel = AuditChannel.ApiOutbound,
|
||||||
AuditKind kind = AuditKind.ApiCall,
|
AuditKind kind = AuditKind.ApiCall,
|
||||||
AuditStatus status = AuditStatus.Delivered,
|
AuditStatus status = AuditStatus.Delivered,
|
||||||
|
|||||||
+2
-2
@@ -3,7 +3,7 @@ using Bunit.TestDoubles;
|
|||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
|
||||||
@@ -29,7 +29,7 @@ public class AuditEventDetailTests : BunitContext
|
|||||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AuditEvent MakeEvent(
|
private static AuditEventView MakeEvent(
|
||||||
AuditChannel channel = AuditChannel.ApiOutbound,
|
AuditChannel channel = AuditChannel.ApiOutbound,
|
||||||
AuditKind kind = AuditKind.ApiCall,
|
AuditKind kind = AuditKind.ApiCall,
|
||||||
AuditStatus status = AuditStatus.Delivered,
|
AuditStatus status = AuditStatus.Delivered,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
@@ -53,7 +52,7 @@ public class AuditFilterBarTests : BunitContext
|
|||||||
_auditLogQueryService.GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>())
|
_auditLogQueryService.GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>())
|
||||||
.Returns(Task.FromResult<IReadOnlyList<string>>(new[] { "central-a", "central-b" }));
|
.Returns(Task.FromResult<IReadOnlyList<string>>(new[] { "central-a", "central-b" }));
|
||||||
_auditLogQueryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
_auditLogQueryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
.Returns(Task.FromResult<IReadOnlyList<AuditEventView>>(Array.Empty<AuditEventView>()));
|
||||||
Services.AddSingleton(_auditLogQueryService);
|
Services.AddSingleton(_auditLogQueryService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-9
@@ -4,7 +4,6 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
@@ -22,7 +21,7 @@ public class AuditResultsGridTests : BunitContext
|
|||||||
private readonly IAuditLogQueryService _service;
|
private readonly IAuditLogQueryService _service;
|
||||||
private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new();
|
private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new();
|
||||||
|
|
||||||
private static AuditEvent MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a", Guid? executionId = null, Guid? parentExecutionId = null)
|
private static AuditEventView MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a", Guid? executionId = null, Guid? parentExecutionId = null)
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
EventId = Guid.NewGuid(),
|
EventId = Guid.NewGuid(),
|
||||||
@@ -53,7 +52,7 @@ public class AuditResultsGridTests : BunitContext
|
|||||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StubPage(IReadOnlyList<AuditEvent> rows)
|
private void StubPage(IReadOnlyList<AuditEventView> rows)
|
||||||
{
|
{
|
||||||
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(callInfo =>
|
.Returns(callInfo =>
|
||||||
@@ -66,7 +65,7 @@ public class AuditResultsGridTests : BunitContext
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Render_TenColumns_FromStubService()
|
public void Render_TenColumns_FromStubService()
|
||||||
{
|
{
|
||||||
StubPage(new List<AuditEvent>
|
StubPage(new List<AuditEventView>
|
||||||
{
|
{
|
||||||
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
|
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
|
||||||
});
|
});
|
||||||
@@ -112,10 +111,10 @@ public class AuditResultsGridTests : BunitContext
|
|||||||
var target = MakeEvent(DateTime.UtcNow.AddMinutes(-5), AuditStatus.Delivered);
|
var target = MakeEvent(DateTime.UtcNow.AddMinutes(-5), AuditStatus.Delivered);
|
||||||
StubPage(new[] { target });
|
StubPage(new[] { target });
|
||||||
|
|
||||||
AuditEvent? captured = null;
|
AuditEventView? captured = null;
|
||||||
var cut = Render<AuditResultsGrid>(p => p
|
var cut = Render<AuditResultsGrid>(p => p
|
||||||
.Add(c => c.Filter, new AuditLogQueryFilter())
|
.Add(c => c.Filter, new AuditLogQueryFilter())
|
||||||
.Add(c => c.OnRowSelected, EventCallback.Factory.Create<AuditEvent>(this, e => captured = e)));
|
.Add(c => c.OnRowSelected, EventCallback.Factory.Create<AuditEventView>(this, e => captured = e)));
|
||||||
|
|
||||||
cut.Find($"[data-test=\"grid-row-{target.EventId}\"]").Click();
|
cut.Find($"[data-test=\"grid-row-{target.EventId}\"]").Click();
|
||||||
|
|
||||||
@@ -128,7 +127,7 @@ public class AuditResultsGridTests : BunitContext
|
|||||||
{
|
{
|
||||||
// Task 15: the grid surfaces SourceNode in a dedicated "Node" column
|
// Task 15: the grid surfaces SourceNode in a dedicated "Node" column
|
||||||
// positioned between Site and Channel.
|
// positioned between Site and Channel.
|
||||||
StubPage(new List<AuditEvent>
|
StubPage(new List<AuditEventView>
|
||||||
{
|
{
|
||||||
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
|
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
|
||||||
});
|
});
|
||||||
@@ -148,7 +147,7 @@ public class AuditResultsGridTests : BunitContext
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Render_IncludesExecutionIdColumn()
|
public void Render_IncludesExecutionIdColumn()
|
||||||
{
|
{
|
||||||
StubPage(new List<AuditEvent>
|
StubPage(new List<AuditEventView>
|
||||||
{
|
{
|
||||||
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
|
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
|
||||||
});
|
});
|
||||||
@@ -191,7 +190,7 @@ public class AuditResultsGridTests : BunitContext
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Render_IncludesParentExecutionIdColumn()
|
public void Render_IncludesParentExecutionIdColumn()
|
||||||
{
|
{
|
||||||
StubPage(new List<AuditEvent>
|
StubPage(new List<AuditEventView>
|
||||||
{
|
{
|
||||||
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
|
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-5
@@ -4,7 +4,6 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
@@ -36,7 +35,7 @@ public class ExecutionDetailModalTests : BunitContext
|
|||||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AuditEvent MakeEvent(
|
private static AuditEventView MakeEvent(
|
||||||
Guid executionId,
|
Guid executionId,
|
||||||
AuditStatus status = AuditStatus.Delivered,
|
AuditStatus status = AuditStatus.Delivered,
|
||||||
AuditChannel channel = AuditChannel.ApiOutbound,
|
AuditChannel channel = AuditChannel.ApiOutbound,
|
||||||
@@ -57,7 +56,7 @@ public class ExecutionDetailModalTests : BunitContext
|
|||||||
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
|
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
|
||||||
};
|
};
|
||||||
|
|
||||||
private void StubRows(IReadOnlyList<AuditEvent> rows)
|
private void StubRows(IReadOnlyList<AuditEventView> rows)
|
||||||
{
|
{
|
||||||
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(callInfo =>
|
.Returns(callInfo =>
|
||||||
@@ -202,7 +201,7 @@ public class ExecutionDetailModalTests : BunitContext
|
|||||||
public void ZeroRow_ShowsFriendlyEmptyState()
|
public void ZeroRow_ShowsFriendlyEmptyState()
|
||||||
{
|
{
|
||||||
var executionId = Guid.NewGuid();
|
var executionId = Guid.NewGuid();
|
||||||
StubRows(Array.Empty<AuditEvent>());
|
StubRows(Array.Empty<AuditEventView>());
|
||||||
|
|
||||||
var cut = Render<ExecutionDetailModal>(p => p
|
var cut = Render<ExecutionDetailModal>(p => p
|
||||||
.Add(c => c.ExecutionId, executionId)
|
.Add(c => c.ExecutionId, executionId)
|
||||||
@@ -217,7 +216,7 @@ public class ExecutionDetailModalTests : BunitContext
|
|||||||
{
|
{
|
||||||
var executionId = Guid.NewGuid();
|
var executionId = Guid.NewGuid();
|
||||||
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||||
.Returns<Task<IReadOnlyList<AuditEvent>>>(_ => throw new InvalidOperationException("db is down"));
|
.Returns<Task<IReadOnlyList<AuditEventView>>>(_ => throw new InvalidOperationException("db is down"));
|
||||||
|
|
||||||
// Rendering with IsOpen=true must not throw — the modal degrades to an
|
// Rendering with IsOpen=true must not throw — the modal degrades to an
|
||||||
// inline error banner rather than killing the SignalR circuit.
|
// inline error banner rather than killing the SignalR circuit.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ using Microsoft.Extensions.Options;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Audit;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ using Microsoft.AspNetCore.Components.Authorization;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
using ZB.MOM.WW.ScadaBridge.Security;
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
@@ -176,7 +175,7 @@ public class AuditLogPageScaffoldTests : BunitContext
|
|||||||
var corr = Guid.Parse("11111111-2222-3333-4444-555555555555");
|
var corr = Guid.Parse("11111111-2222-3333-4444-555555555555");
|
||||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
.Returns(Task.FromResult<IReadOnlyList<AuditEventView>>(new List<AuditEventView>()));
|
||||||
|
|
||||||
var cut = RenderAuditLogPageWithQuery($"correlationId={corr}", "Administrator");
|
var cut = RenderAuditLogPageWithQuery($"correlationId={corr}", "Administrator");
|
||||||
|
|
||||||
@@ -199,7 +198,7 @@ public class AuditLogPageScaffoldTests : BunitContext
|
|||||||
var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
.Returns(Task.FromResult<IReadOnlyList<AuditEventView>>(new List<AuditEventView>()));
|
||||||
|
|
||||||
var cut = RenderAuditLogPageWithQuery($"executionId={executionId}", "Administrator");
|
var cut = RenderAuditLogPageWithQuery($"executionId={executionId}", "Administrator");
|
||||||
|
|
||||||
@@ -237,7 +236,7 @@ public class AuditLogPageScaffoldTests : BunitContext
|
|||||||
var parentExecutionId = Guid.Parse("aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb");
|
var parentExecutionId = Guid.Parse("aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb");
|
||||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
.Returns(Task.FromResult<IReadOnlyList<AuditEventView>>(new List<AuditEventView>()));
|
||||||
|
|
||||||
var cut = RenderAuditLogPageWithQuery($"parentExecutionId={parentExecutionId}", "Administrator");
|
var cut = RenderAuditLogPageWithQuery($"parentExecutionId={parentExecutionId}", "Administrator");
|
||||||
|
|
||||||
@@ -272,7 +271,7 @@ public class AuditLogPageScaffoldTests : BunitContext
|
|||||||
{
|
{
|
||||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
.Returns(Task.FromResult<IReadOnlyList<AuditEventView>>(new List<AuditEventView>()));
|
||||||
|
|
||||||
var cut = RenderAuditLogPageWithQuery("target=ExternalSystem-Alpha", "Administrator");
|
var cut = RenderAuditLogPageWithQuery("target=ExternalSystem-Alpha", "Administrator");
|
||||||
|
|
||||||
@@ -290,7 +289,7 @@ public class AuditLogPageScaffoldTests : BunitContext
|
|||||||
{
|
{
|
||||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
.Returns(Task.FromResult<IReadOnlyList<AuditEventView>>(new List<AuditEventView>()));
|
||||||
|
|
||||||
var cut = RenderAuditLogPageWithQuery("site=plant-a", "Administrator");
|
var cut = RenderAuditLogPageWithQuery("site=plant-a", "Administrator");
|
||||||
|
|
||||||
@@ -312,7 +311,7 @@ public class AuditLogPageScaffoldTests : BunitContext
|
|||||||
// builds an AuditLogQueryFilter with Status set, and auto-loads.
|
// builds an AuditLogQueryFilter with Status set, and auto-loads.
|
||||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
.Returns(Task.FromResult<IReadOnlyList<AuditEventView>>(new List<AuditEventView>()));
|
||||||
|
|
||||||
var cut = RenderAuditLogPageWithQuery("status=Failed", "Administrator");
|
var cut = RenderAuditLogPageWithQuery("status=Failed", "Administrator");
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ using Microsoft.AspNetCore.Components.Authorization;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
using ZB.MOM.WW.ScadaBridge.Security;
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
@@ -127,7 +126,7 @@ public class ExecutionTreePageTests : BunitContext
|
|||||||
}));
|
}));
|
||||||
// The modal loads the double-clicked execution's audit rows on open.
|
// The modal loads the double-clicked execution's audit rows on open.
|
||||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
.Returns(Task.FromResult<IReadOnlyList<AuditEventView>>(Array.Empty<AuditEventView>()));
|
||||||
// AuditEventDetail (reachable from the modal) owns a clipboard interop call.
|
// AuditEventDetail (reachable from the modal) owns a clipboard interop call.
|
||||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||||
|
|
||||||
@@ -160,7 +159,7 @@ public class ExecutionTreePageTests : BunitContext
|
|||||||
Node(child, root),
|
Node(child, root),
|
||||||
}));
|
}));
|
||||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
.Returns(Task.FromResult<IReadOnlyList<AuditEventView>>(Array.Empty<AuditEventView>()));
|
||||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||||
|
|
||||||
var cut = RenderPage($"executionId={child}", "Administrator");
|
var cut = RenderPage($"executionId={child}", "Administrator");
|
||||||
|
|||||||
+44
-77
@@ -1,7 +1,7 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
@@ -22,31 +22,22 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuditLogExportServiceTests
|
public class AuditLogExportServiceTests
|
||||||
{
|
{
|
||||||
|
// C3 (Task 2.5): the export service reads canonical ZB.MOM.WW.Audit.AuditEvent rows
|
||||||
|
// from IAuditLogRepository and decomposes each into the flat 21-column CSV shape;
|
||||||
|
// build the canonical rows via the shared factory (domain fields ride in DetailsJson).
|
||||||
private static AuditEvent SimpleEvent(string id, string? target = null, string? error = null)
|
private static AuditEvent SimpleEvent(string id, string? target = null, string? error = null)
|
||||||
=> new()
|
=> ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
channel: AuditChannel.ApiOutbound,
|
||||||
EventId = Guid.Parse(id),
|
kind: AuditKind.ApiCall,
|
||||||
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
status: AuditStatus.Delivered,
|
||||||
IngestedAtUtc = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc),
|
eventId: Guid.Parse(id),
|
||||||
Channel = AuditChannel.ApiOutbound,
|
occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
||||||
Kind = AuditKind.ApiCall,
|
target: target,
|
||||||
CorrelationId = null,
|
sourceSiteId: "plant-a",
|
||||||
SourceSiteId = "plant-a",
|
httpStatus: 200,
|
||||||
SourceInstanceId = null,
|
durationMs: 42,
|
||||||
SourceScript = null,
|
errorMessage: error,
|
||||||
Actor = null,
|
ingestedAtUtc: new DateTimeOffset(new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc)));
|
||||||
Target = target,
|
|
||||||
Status = AuditStatus.Delivered,
|
|
||||||
HttpStatus = 200,
|
|
||||||
DurationMs = 42,
|
|
||||||
ErrorMessage = error,
|
|
||||||
ErrorDetail = null,
|
|
||||||
RequestSummary = null,
|
|
||||||
ResponseSummary = null,
|
|
||||||
PayloadTruncated = false,
|
|
||||||
Extra = null,
|
|
||||||
ForwardState = null,
|
|
||||||
};
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ExportAsync_FiveRows_WritesHeaderPlusFiveRows()
|
public async Task ExportAsync_FiveRows_WritesHeaderPlusFiveRows()
|
||||||
@@ -115,30 +106,16 @@ public class AuditLogExportServiceTests
|
|||||||
// Target contains a comma → field must be wrapped in double quotes.
|
// Target contains a comma → field must be wrapped in double quotes.
|
||||||
// Target with embedded quote → quote must be doubled ("") and field quoted.
|
// Target with embedded quote → quote must be doubled ("") and field quoted.
|
||||||
// ResponseSummary contains CR-LF → field must be quoted.
|
// ResponseSummary contains CR-LF → field must be quoted.
|
||||||
var row = new AuditEvent
|
var row = ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
channel: AuditChannel.ApiOutbound,
|
||||||
EventId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
kind: AuditKind.ApiCall,
|
||||||
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
status: AuditStatus.Delivered,
|
||||||
IngestedAtUtc = null,
|
eventId: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
||||||
Channel = AuditChannel.ApiOutbound,
|
occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
||||||
Kind = AuditKind.ApiCall,
|
target: "x",
|
||||||
CorrelationId = null,
|
sourceSiteId: "plant-a, secondary", // comma
|
||||||
SourceSiteId = "plant-a, secondary", // comma
|
sourceScript: "say \"hi\"", // embedded quote
|
||||||
SourceInstanceId = null,
|
errorMessage: "boom\r\nthen again"); // CR-LF
|
||||||
SourceScript = "say \"hi\"", // embedded quote
|
|
||||||
Actor = null,
|
|
||||||
Target = "x",
|
|
||||||
Status = AuditStatus.Delivered,
|
|
||||||
HttpStatus = null,
|
|
||||||
DurationMs = null,
|
|
||||||
ErrorMessage = "boom\r\nthen again", // CR-LF
|
|
||||||
ErrorDetail = null,
|
|
||||||
RequestSummary = null,
|
|
||||||
ResponseSummary = null,
|
|
||||||
PayloadTruncated = false,
|
|
||||||
Extra = null,
|
|
||||||
ForwardState = null,
|
|
||||||
};
|
|
||||||
var repo = Substitute.For<IAuditLogRepository>();
|
var repo = Substitute.For<IAuditLogRepository>();
|
||||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(
|
.Returns(
|
||||||
@@ -163,30 +140,12 @@ public class AuditLogExportServiceTests
|
|||||||
public async Task ExportAsync_NullField_WrittenAsEmpty()
|
public async Task ExportAsync_NullField_WrittenAsEmpty()
|
||||||
{
|
{
|
||||||
// Build a row with deliberate nulls for every nullable column.
|
// Build a row with deliberate nulls for every nullable column.
|
||||||
var row = new AuditEvent
|
var row = ScadaBridgeAuditEventFactory.Create(
|
||||||
{
|
channel: AuditChannel.ApiOutbound,
|
||||||
EventId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
|
kind: AuditKind.ApiCall,
|
||||||
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
status: AuditStatus.Submitted,
|
||||||
IngestedAtUtc = null,
|
eventId: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
|
||||||
Channel = AuditChannel.ApiOutbound,
|
occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc));
|
||||||
Kind = AuditKind.ApiCall,
|
|
||||||
CorrelationId = null,
|
|
||||||
SourceSiteId = null,
|
|
||||||
SourceInstanceId = null,
|
|
||||||
SourceScript = null,
|
|
||||||
Actor = null,
|
|
||||||
Target = null,
|
|
||||||
Status = AuditStatus.Submitted,
|
|
||||||
HttpStatus = null,
|
|
||||||
DurationMs = null,
|
|
||||||
ErrorMessage = null,
|
|
||||||
ErrorDetail = null,
|
|
||||||
RequestSummary = null,
|
|
||||||
ResponseSummary = null,
|
|
||||||
PayloadTruncated = false,
|
|
||||||
Extra = null,
|
|
||||||
ForwardState = null,
|
|
||||||
};
|
|
||||||
var repo = Substitute.For<IAuditLogRepository>();
|
var repo = Substitute.For<IAuditLogRepository>();
|
||||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(
|
.Returns(
|
||||||
@@ -278,14 +237,20 @@ public class AuditLogExportServiceTests
|
|||||||
{
|
{
|
||||||
// Two pages of 2 rows each, then empty. The service must pass the last
|
// Two pages of 2 rows each, then empty. The service must pass the last
|
||||||
// row of page 1 as the cursor on the page-2 call.
|
// row of page 1 as the cursor on the page-2 call.
|
||||||
|
AuditEvent Row(string id, DateTime occurredAt) => ScadaBridgeAuditEventFactory.Create(
|
||||||
|
channel: AuditChannel.ApiOutbound,
|
||||||
|
kind: AuditKind.ApiCall,
|
||||||
|
status: AuditStatus.Delivered,
|
||||||
|
eventId: Guid.Parse(id),
|
||||||
|
occurredAtUtc: occurredAt);
|
||||||
var p1 = new List<AuditEvent>
|
var p1 = new List<AuditEvent>
|
||||||
{
|
{
|
||||||
new() { EventId = Guid.Parse("11111111-1111-1111-1111-111111111111"), OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered },
|
Row("11111111-1111-1111-1111-111111111111", new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc)),
|
||||||
new() { EventId = Guid.Parse("22222222-2222-2222-2222-222222222222"), OccurredAtUtc = new DateTime(2026, 5, 20, 11, 59, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered },
|
Row("22222222-2222-2222-2222-222222222222", new DateTime(2026, 5, 20, 11, 59, 0, DateTimeKind.Utc)),
|
||||||
};
|
};
|
||||||
var p2 = new List<AuditEvent>
|
var p2 = new List<AuditEvent>
|
||||||
{
|
{
|
||||||
new() { EventId = Guid.Parse("33333333-3333-3333-3333-333333333333"), OccurredAtUtc = new DateTime(2026, 5, 20, 11, 58, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered },
|
Row("33333333-3333-3333-3333-333333333333", new DateTime(2026, 5, 20, 11, 58, 0, DateTimeKind.Utc)),
|
||||||
};
|
};
|
||||||
|
|
||||||
var pagings = new List<AuditLogPaging>();
|
var pagings = new List<AuditLogPaging>();
|
||||||
@@ -305,6 +270,8 @@ public class AuditLogExportServiceTests
|
|||||||
Assert.Null(pagings[0].AfterEventId);
|
Assert.Null(pagings[0].AfterEventId);
|
||||||
Assert.Null(pagings[0].AfterOccurredAtUtc);
|
Assert.Null(pagings[0].AfterOccurredAtUtc);
|
||||||
Assert.Equal(p1[^1].EventId, pagings[1].AfterEventId);
|
Assert.Equal(p1[^1].EventId, pagings[1].AfterEventId);
|
||||||
Assert.Equal(p1[^1].OccurredAtUtc, pagings[1].AfterOccurredAtUtc);
|
// Canonical OccurredAtUtc is a DateTimeOffset; the paging cursor is a DateTime —
|
||||||
|
// compare via the decomposed row view (Kind=Utc DateTime).
|
||||||
|
Assert.Equal(p1[^1].AsRow().OccurredAtUtc, pagings[1].AfterOccurredAtUtc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user