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:
Joseph Doherty
2026-06-02 12:37:50 -04:00
parent 5aaf9e2923
commit db707bb0de
127 changed files with 2240 additions and 3886 deletions
@@ -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>.&lt;sql-snippet&gt;</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;
@@ -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; }
}
@@ -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");
@@ -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)
{ {
@@ -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();
@@ -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);
@@ -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);
@@ -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>
@@ -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;
@@ -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);
@@ -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);
@@ -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);
@@ -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));
} }
} }
@@ -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);
} }
} }
@@ -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;
@@ -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);
@@ -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);
@@ -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);
@@ -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>&lt;redacted&gt;</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>&lt;redacted: redactor error&gt;</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>"&lt;redacted&gt;"</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&lt;T&gt;</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>&lt;redacted: redactor error&gt;</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>&lt;redacted&gt;</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]
@@ -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);
@@ -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) ──
@@ -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(
@@ -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)
{ {
@@ -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,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,
@@ -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);
} }
@@ -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,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");
@@ -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