diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogIngestActor.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogIngestActor.cs
index 4b81c7aa..6feffc60 100644
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogIngestActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogIngestActor.cs
@@ -1,10 +1,11 @@
using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
-using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
-using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
+using ZB.MOM.WW.Audit;
+using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
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
/// rows pushed from sites via the
/// IngestAuditEvents gRPC RPC. Each row is stamped with the central-side
-/// and inserted idempotently via
+/// the central-side IngestedAtUtc (in DetailsJson) and inserted idempotently via
/// — duplicates are
/// silently swallowed (first-write-wins per Bundle A's hardening).
///
@@ -127,19 +128,19 @@ public class AuditLogIngestActor : ReceiveActor
// without blocking on sync Dispose() of pending connection cleanup.
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);
}
else
{
await using var scope = _serviceProvider!.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService();
- var filter = scope.ServiceProvider.GetService();
+ var redactor = scope.ServiceProvider.GetService();
// M6 Bundle E (T8): central health counter is best-effort —
// unregistered (test composition roots) means the per-row catch
// simply logs without surfacing on the health dashboard.
var failureCounter = scope.ServiceProvider.GetService();
- await IngestWithRepositoryAsync(repository, filter, failureCounter, cmd, nowUtc, accepted)
+ await IngestWithRepositoryAsync(repository, redactor, failureCounter, cmd, nowUtc, accepted)
.ConfigureAwait(false);
}
@@ -148,7 +149,7 @@ public class AuditLogIngestActor : ReceiveActor
private async Task IngestWithRepositoryAsync(
IAuditLogRepository repository,
- IAuditPayloadFilter? filter,
+ IAuditRedactor? redactor,
ICentralAuditWriteFailureCounter? failureCounter,
IngestAuditEventsCommand cmd,
DateTime nowUtc,
@@ -162,15 +163,17 @@ public class AuditLogIngestActor : ReceiveActor
// repository hardening already swallows duplicate-key races,
// so the same id arriving twice (site retry, reconciliation)
// is a silent no-op.
- // Filter BEFORE the IngestedAtUtc stamp so the redacted
- // copy carries the central-side ingest timestamp. Filter
+ // Redact BEFORE the IngestedAtUtc stamp so the redacted
+ // copy carries the central-side ingest timestamp. The redactor
// 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
// pass-through, so HTTP header redaction always runs.
- var safeFilter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
- var filtered = safeFilter.Apply(evt);
- var ingested = filtered with { IngestedAtUtc = nowUtc };
+ // C3 transitional shim: IngestedAtUtc is a DetailsJson field on
+ // the canonical record, so stamp it via the projection helper.
+ var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
+ var filtered = safeRedactor.Apply(evt);
+ var ingested = AuditRowProjection.WithIngestedAtUtc(filtered, nowUtc);
await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
accepted.Add(evt.EventId);
}
@@ -216,12 +219,12 @@ public class AuditLogIngestActor : ReceiveActor
var auditRepo = scope.ServiceProvider.GetRequiredService();
var siteCallRepo = scope.ServiceProvider.GetRequiredService();
var dbContext = scope.ServiceProvider.GetRequiredService();
- // Bundle C (M5-T6): resolve the filter for the whole batch from
- // the scope; null = pass-through for test composition roots that
- // skip the filter registration. The filter is contract-bound to
+ // Bundle C (M5-T6): resolve the redactor for the whole batch from
+ // the scope; null = SafeDefault for test composition roots that
+ // skip the redactor registration. The redactor is contract-bound to
// never throw, so we can apply it inside the per-entry try
// without risking an unbounded blast radius.
- var filter = scope.ServiceProvider.GetService();
+ var redactor = scope.ServiceProvider.GetService();
// M6 Bundle E (T8): same best-effort central health counter as
// the OnIngestAsync path — null on test composition roots that
// skip the registration.
@@ -240,14 +243,16 @@ public class AuditLogIngestActor : ReceiveActor
// matching timestamps (debugging convenience, not a
// correctness invariant).
var ingestedAt = DateTime.UtcNow;
- // Filter the audit half BEFORE the dual-write — only the
- // AuditLog row's payload columns are filterable; SiteCalls
+ // Redact the audit half BEFORE the dual-write — only the
+ // AuditLog row's payload columns are redactable; SiteCalls
// 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.
- var safeFilter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
- var filteredAudit = safeFilter.Apply(entry.Audit);
- var auditStamped = filteredAudit with { IngestedAtUtc = ingestedAt };
+ // C3 transitional shim: IngestedAtUtc is a DetailsJson field
+ // on the canonical record, so stamp it via the projection helper.
+ var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
+ var filteredAudit = safeRedactor.Apply(entry.Audit);
+ var auditStamped = AuditRowProjection.WithIngestedAtUtc(filteredAudit, ingestedAt);
var siteCallStamped = entry.SiteCall with { IngestedAtUtc = ingestedAt };
await auditRepo.InsertIfNotExistsAsync(auditStamped)
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/CentralAuditWriter.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/CentralAuditWriter.cs
index 1a17d6b6..0bff3bd3 100644
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/CentralAuditWriter.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/CentralAuditWriter.cs
@@ -1,9 +1,10 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
-using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
-using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
+using ZB.MOM.WW.Audit;
+using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
@@ -41,7 +42,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
{
private readonly IServiceProvider _services;
private readonly ILogger _logger;
- private readonly IAuditPayloadFilter _filter;
+ private readonly IAuditRedactor _redactor;
private readonly ICentralAuditWriteFailureCounter _failureCounter;
private readonly INodeIdentityProvider? _nodeIdentity;
@@ -68,24 +69,25 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
///
/// Service provider used to open a per-call scope for the scoped repository.
/// Logger for swallowed write-failure diagnostics.
- /// Optional payload filter for truncation and redaction; defaults to a pass-through.
+ /// Optional canonical redactor for truncation and redaction; defaults to the always-safe default.
/// Optional counter incremented on swallowed repository failures; defaults to a no-op.
/// Optional node identity provider for stamping SourceNode on central-origin rows.
public CentralAuditWriter(
IServiceProvider services,
ILogger logger,
- IAuditPayloadFilter? filter = null,
+ IAuditRedactor? redactor = null,
ICentralAuditWriteFailureCounter? failureCounter = null,
INodeIdentityProvider? nodeIdentity = null)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// AuditLog-008: never default to null — over-redact instead.
- // SafeDefaultAuditPayloadFilter applies HTTP header redaction with
- // hard-coded sensitive defaults so a composition root that omits the
- // real filter still scrubs Authorization / X-Api-Key / Cookie /
- // Set-Cookie before persistence.
- _filter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
+ // C3 (Task 2.5): the canonical IAuditRedactor replaces the legacy
+ // IAuditPayloadFilter. SafeDefaultAuditRedactor applies HTTP header
+ // redaction with hard-coded sensitive defaults so a composition root
+ // that omits the real redactor still scrubs Authorization / X-Api-Key /
+ // Cookie / Set-Cookie before persistence.
+ _redactor = redactor ?? SafeDefaultAuditRedactor.Instance;
_failureCounter = failureCounter ?? new NoOpCentralAuditWriteFailureCounter();
_nodeIdentity = nodeIdentity;
}
@@ -103,12 +105,12 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
try
{
- // Filter BEFORE stamping IngestedAtUtc + handing to the repo. The
- // filter contract is "never throws". AuditLog-008: _filter is now
- // non-null (SafeDefaultAuditPayloadFilter fallback) so header
+ // Redact BEFORE stamping IngestedAtUtc + handing to the repo. The
+ // redactor contract is "never throws". AuditLog-008: _redactor is
+ // now non-null (SafeDefaultAuditRedactor fallback) so header
// redaction always runs even in composition roots that omit the
- // real filter.
- var filtered = _filter.Apply(evt);
+ // real redactor.
+ var filtered = _redactor.Apply(evt);
// SourceNode-stamping (Task 12): caller-provided value wins
// (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();
var repo = scope.ServiceProvider.GetRequiredService();
- 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);
}
catch (Exception ex)
@@ -143,17 +147,17 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
// misbehaving custom counter does, swallowing here keeps the
// best-effort contract intact.
}
- // Log the input event's identifying fields. These three (EventId,
- // Kind, Status) are immutable across the filter+stamp chain — the
- // `with` clones above touch only SourceNode and IngestedAtUtc — so
- // referencing `evt` here is intentional and equivalent to the
- // stamped record for diagnostics. If you add a field here that the
- // stamp chain DOES mutate (e.g., SourceNode), reference the latest
- // post-stamp record name instead, not `evt`.
+ // Log the input event's identifying fields. EventId + Action are
+ // immutable across the redact+stamp chain — the `with` clones above
+ // touch only SourceNode and DetailsJson — so referencing `evt` here
+ // is intentional and equivalent to the stamped record for
+ // diagnostics. Action = "{Channel}.{Kind}" carries the kind; the
+ // canonical Outcome carries the coarse status (fine-grained Status
+ // lives in DetailsJson).
_logger.LogWarning(
ex,
- "CentralAuditWriter failed for EventId {EventId} (Kind={Kind}, Status={Status})",
- evt.EventId, evt.Kind, evt.Status);
+ "CentralAuditWriter failed for EventId {EventId} (Action={Action}, Outcome={Outcome})",
+ evt.EventId, evt.Action, evt.Outcome);
}
}
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/SiteAuditReconciliationActor.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/SiteAuditReconciliationActor.cs
index 19149722..fb08bc57 100644
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/SiteAuditReconciliationActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/SiteAuditReconciliationActor.cs
@@ -2,8 +2,8 @@ using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
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.Types.Audit;
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
// a no-op courtesy of M2 Bundle A's race-fix on
// 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);
_failedInsertAttempts.Remove(evt.EventId);
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;
}
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/DefaultAuditPayloadFilter.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/DefaultAuditPayloadFilter.cs
deleted file mode 100644
index 5d36730d..00000000
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/DefaultAuditPayloadFilter.cs
+++ /dev/null
@@ -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;
-
-///
-/// Default . 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.
-///
-///
-///
-/// Uses (not )
-/// so the M5-T8 hot-reload path sees fresh values without re-resolving the
-/// singleton. reads
-/// 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 OnChange subscription
-/// or explicit cache invalidation is required (the
-/// AuditLogOptionsBindingTests fixture in ZB.MOM.WW.ScadaBridge.AuditLog.Tests
-/// pins this behaviour).
-///
-///
-/// "Error row" = NOT IN (Delivered,
-/// Submitted, Forwarded) — every other status, including the
-/// non-terminal Attempted, the parked/discarded terminals, and the
-/// short-circuit Skipped, receives the larger error cap so a verbose
-/// error body survives.
-///
-///
-/// Apply MUST NOT throw — on internal failure the filter over-redacts by
-/// returning the input with set and
-/// increments the AuditRedactionFailure health metric via the injected
-/// . 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.
-///
-///
-/// 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.
-///
-///
-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 _options;
- private readonly ILogger _logger;
- private readonly IAuditRedactionFailureCounter _failureCounter;
- private readonly AuditRegexCache _regexCache;
-
- ///
- /// Primary constructor used by DI — pulls the optional redaction-failure
- /// counter from the container; a NoOp default is registered in
- /// .
- ///
- /// Live-reloadable audit log options.
- /// Logger for redaction diagnostics.
- /// Optional counter incremented when a redaction operation fails; defaults to a no-op.
- public DefaultAuditPayloadFilter(
- IOptionsMonitor options,
- ILogger 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);
- }
-
- ///
- 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 };
- }
- }
-
- ///
- /// Parse as the documented
- /// {"headers": {...}, "body": ...} shape and replace values whose
- /// header NAME (case-insensitive) is in with
- /// the redaction marker. Re-serialises and returns the result. Delegates to
- /// .
- ///
- ///
- /// 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.
- ///
- private string? RedactHeaders(string? json, IList redactList)
- => AuditRedactionPrimitives.RedactHeaders(json, redactList, _logger, IncrementFailureCounter);
-
- ///
- /// 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.
- ///
- private IReadOnlyList 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();
- }
-
- var result = new List();
- 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;
- }
-
- ///
- /// Apply each compiled body-redactor regex to in
- /// turn, replacing every match with the redaction marker. If any single
- /// regex match throws (most commonly
- /// ) 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
- /// .
- ///
- private string? RedactBody(string? value, IReadOnlyList regexes)
- => AuditRedactionPrimitives.RedactBody(value, regexes, _logger, IncrementFailureCounter);
-
- ///
- /// Resolve the per-connection SQL parameter redaction regex for the given
- /// DbOutbound event target. Target shape (M4 AuditingDbCommand): the
- /// connection name optionally followed by .<sql-snippet> 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.
- ///
- 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;
- }
-
- ///
- /// Walk the M4 {"sql":"...","parameters":{...}} RequestSummary
- /// shape; for each parameter whose NAME matches
- /// , replace its value with the redaction
- /// marker. Re-serialise. Delegates to
- /// .
- ///
- ///
- /// No-op pass-through when the input isn't parseable JSON, isn't a JSON
- /// object, or doesn't carry a top-level "parameters" object. On
- /// any unexpected fault the field is over-redacted and the failure counter
- /// is bumped.
- ///
- 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);
-
- ///
- /// 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 onFailure callback to the shared primitives.
- ///
- 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,
- };
-}
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/IAuditPayloadFilter.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/IAuditPayloadFilter.cs
deleted file mode 100644
index c2c39902..00000000
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/IAuditPayloadFilter.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
-
-namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
-
-///
-/// Filters an between construction and persistence —
-/// truncates oversized payload fields, applies header/body/SQL-parameter
-/// redaction, sets .
-///
-///
-///
-/// Pure function: returns a filtered COPY of the input via with
-/// expressions; never throws (over-redacts on internal failure and increments
-/// the AuditRedactionFailure health metric).
-///
-///
-/// Wired in M5 between event construction and the writer chain
-/// (FallbackAuditWriter.WriteAsync, CentralAuditWriter.WriteAsync,
-/// and the AuditLogIngestActor handlers).
-///
-///
-public interface IAuditPayloadFilter
-{
- ///
- /// Apply the configured truncation + redaction policy to
- /// and return a filtered copy. MUST NOT throw — on internal failure, over-redact
- /// and surface the failure via the audit-redaction-failure health metric.
- ///
- /// The unfiltered audit event to process.
- AuditEvent Apply(AuditEvent rawEvent);
-}
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/SafeDefaultAuditPayloadFilter.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/SafeDefaultAuditPayloadFilter.cs
deleted file mode 100644
index 1813e00d..00000000
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/SafeDefaultAuditPayloadFilter.cs
+++ /dev/null
@@ -1,79 +0,0 @@
-using System.Text.RegularExpressions;
-using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
-
-namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
-
-///
-/// AuditLog-008: minimal always-safe fallback filter used by the writer chain
-/// when no is injected (test composition
-/// roots, future composition roots that bypass AddAuditLog). Performs
-/// HTTP header redaction for the always-sensitive defaults
-/// (Authorization, X-Api-Key, Cookie, Set-Cookie) so a fixture that wires a
-/// real never persists those headers
-/// in cleartext. Does NOT perform body-regex redaction, SQL-parameter
-/// redaction, or truncation — those stages need
-/// with live options. The contract is:
-/// over-redact safely, never throw, never miss a header that's on the
-/// default sensitive list.
-///
-public sealed class SafeDefaultAuditPayloadFilter : IAuditPayloadFilter
-{
- /// Singleton instance — the filter is stateless and side-effect-free.
- 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(
- @"(?[A-Za-z][A-Za-z0-9\-_]*)\s*:\s*(?[^\r\n]*)",
- RegexOptions.Compiled | RegexOptions.IgnoreCase);
-
- private SafeDefaultAuditPayloadFilter() { }
-
- ///
- 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;
- });
- }
-}
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs
index f065bbbd..0f9680b7 100644
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs
@@ -3,13 +3,16 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Configuration;
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.Redaction;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
+using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
namespace ZB.MOM.WW.ScadaBridge.AuditLog;
@@ -69,14 +72,15 @@ public static class ServiceCollectionExtensions
// validator (a strict improvement over the previous AddSingleton).
services.AddValidatedOptions(config, ConfigSectionName);
- // M5 Bundle A: payload filter — truncates oversized RequestSummary /
- // ResponseSummary / ErrorDetail / Extra fields between event
- // construction and persistence. Bundle B layers header / body /
- // SQL-parameter redaction onto the same singleton; Bundle C wires it
- // into the FallbackAuditWriter / CentralAuditWriter / IngestActor
- // paths. Singleton — the filter is stateless and the IOptionsMonitor
- // dependency picks up M5-T8 hot reloads on its own.
- services.AddSingleton();
+ // C3 (Task 2.5): the canonical IAuditRedactor replaces the legacy
+ // IAuditPayloadFilter in the writer pipeline. ScadaBridgeAuditRedactor
+ // is the port of DefaultAuditPayloadFilter onto the canonical record +
+ // its DetailsJson payload bag — same truncation + header / body /
+ // SQL-parameter redaction, applied between event construction and
+ // persistence. Singleton — stateless; the IOptionsMonitor dependency
+ // picks up hot reloads on its own. The old IAuditPayloadFilter classes
+ // are retained but no longer wired into any pipeline (C7 deletes them).
+ services.AddSingleton();
// M5 Bundle B: per-stage redactor-failure counter. NoOp default;
// 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 +
// counter), not the raw SqliteAuditWriter — primary failures must NEVER
// 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
// truncated + redacted before it hits SQLite (and the ring on
// failure).
@@ -124,7 +128,7 @@ public static class ServiceCollectionExtensions
ring: sp.GetRequiredService(),
failureCounter: sp.GetRequiredService(),
logger: sp.GetRequiredService>(),
- filter: sp.GetRequiredService()));
+ redactor: sp.GetRequiredService()));
// ISiteStreamAuditClient: NoOp default. This binding remains correct for
// 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
// do not accidentally bind it; central composition roots that include
// 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
// before they hit MS SQL.
// M6 Bundle E (T8): also wire the ICentralAuditWriteFailureCounter
@@ -210,7 +214,7 @@ public static class ServiceCollectionExtensions
services.AddSingleton(sp => new CentralAuditWriter(
sp,
sp.GetRequiredService>(),
- sp.GetRequiredService(),
+ sp.GetRequiredService(),
sp.GetRequiredService(),
// SourceNode-stamping (Task 12): wire the local node identity so
// central-origin rows (Notification Outbox dispatch, Inbound API)
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/FallbackAuditWriter.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/FallbackAuditWriter.cs
index 39080427..94edd56d 100644
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/FallbackAuditWriter.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/FallbackAuditWriter.cs
@@ -1,7 +1,8 @@
using Microsoft.Extensions.Logging;
-using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
-using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
+using ZB.MOM.WW.Audit;
+using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
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;
@@ -31,7 +32,7 @@ public sealed class FallbackAuditWriter : IAuditWriter
private readonly RingBufferFallback _ring;
private readonly IAuditWriteFailureCounter _failureCounter;
private readonly ILogger _logger;
- private readonly IAuditPayloadFilter _filter;
+ private readonly IAuditRedactor _redactor;
private readonly SemaphoreSlim _drainGate = new(1, 1);
///
@@ -48,26 +49,28 @@ public sealed class FallbackAuditWriter : IAuditWriter
/// Drop-oldest ring buffer used to stash events when the primary fails.
/// Counter incremented on each primary failure for health reporting.
/// Logger for diagnostics.
- /// Optional payload filter applied before writing; null means no filtering.
+ /// Optional canonical redactor applied before writing; null means the always-safe default.
public FallbackAuditWriter(
IAuditWriter primary,
RingBufferFallback ring,
IAuditWriteFailureCounter failureCounter,
ILogger logger,
- IAuditPayloadFilter? filter = null)
+ IAuditRedactor? redactor = null)
{
_primary = primary ?? throw new ArgumentNullException(nameof(primary));
_ring = ring ?? throw new ArgumentNullException(nameof(ring));
_failureCounter = failureCounter ?? throw new ArgumentNullException(nameof(failureCounter));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
- // AuditLog-008: never default to a null filter — over-redact instead.
- // SafeDefaultAuditPayloadFilter.Instance performs HTTP header
+ // AuditLog-008: never default to a null redactor — over-redact instead.
+ // C3 (Task 2.5): the canonical IAuditRedactor replaces the legacy
+ // IAuditPayloadFilter. SafeDefaultAuditRedactor performs HTTP header
// redaction with the hard-coded sensitive defaults (Authorization,
- // X-Api-Key, Cookie, Set-Cookie) so a test composition root that
- // doesn't bind the real options never persists those headers
- // verbatim. The real DefaultAuditPayloadFilter (truncation + body /
- // SQL-param redaction) is wired by AddAuditLog and takes precedence.
- _filter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
+ // X-Api-Key, Cookie, Set-Cookie) on the DetailsJson summaries so a test
+ // composition root that doesn't bind the real options never persists
+ // those headers verbatim. The full ScadaBridgeAuditRedactor (truncation
+ // + body / SQL-param redaction) is wired by AddAuditLog and takes
+ // precedence.
+ _redactor = redactor ?? SafeDefaultAuditRedactor.Instance;
}
///
@@ -75,14 +78,14 @@ public sealed class FallbackAuditWriter : IAuditWriter
{
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
// drains later still hands the SqliteAuditWriter a row that has
- // already been truncated and redacted. The filter contract is
- // "MUST NOT throw". AuditLog-008: _filter is now non-null (defaults
- // to SafeDefaultAuditPayloadFilter so header redaction is always
- // applied even in composition roots that don't wire the real filter).
- var filtered = _filter.Apply(evt);
+ // already been truncated and redacted. The redactor contract is
+ // "MUST NOT throw". AuditLog-008: _redactor is now non-null (defaults
+ // to SafeDefaultAuditRedactor so header redaction is always applied
+ // even in composition roots that don't wire the real redactor).
+ var filtered = _redactor.Apply(evt);
try
{
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/RingBufferFallback.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/RingBufferFallback.cs
index f13b99f2..f2728fb0 100644
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/RingBufferFallback.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/RingBufferFallback.cs
@@ -1,6 +1,6 @@
using System.Runtime.CompilerServices;
using System.Threading.Channels;
-using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
+using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriter.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriter.cs
index 168151b8..d07c7d92 100644
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriter.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriter.cs
@@ -2,10 +2,11 @@ using System.Threading.Channels;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
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.Types;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
@@ -236,14 +237,10 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
{
ArgumentNullException.ThrowIfNull(evt);
- // Site rows always carry a non-null ForwardState; central rows leave it
- // null. Force Pending on enqueue so callers can pass a bare AuditEvent
- // without thinking about site-vs-central provenance.
- var siteEvt = evt.ForwardState is null
- ? evt with { ForwardState = AuditForwardState.Pending }
- : evt;
-
- var pending = new PendingAuditEvent(siteEvt);
+ // C3 transitional shim: the canonical record carries no ForwardState
+ // (a site-storage-only concern). Site rows always start Pending; the
+ // forwarding columns + queries are unchanged from the 24-column schema.
+ var pending = new PendingAuditEvent(evt, AuditForwardState.Pending);
// CreateBounded(FullMode=Wait) means WriteAsync will await room rather
// 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)
{
- var e = pending.Event;
- pEventId.Value = e.EventId.ToString();
- pOccurredAt.Value = e.OccurredAtUtc.ToString("o");
- pChannel.Value = e.Channel.ToString();
- pKind.Value = e.Kind.ToString();
- pCorrelationId.Value = (object?)e.CorrelationId?.ToString() ?? DBNull.Value;
- pSourceSiteId.Value = (object?)e.SourceSiteId ?? DBNull.Value;
+ // C3 transitional shim: decompose the canonical record into
+ // the typed 24-column values the existing SQLite schema
+ // expects (Channel/Kind/Status + the DetailsJson domain
+ // fields). ForwardState rides alongside the canonical record
+ // (site-storage-only) and is bound from pending.ForwardState.
+ var r = AuditRowProjection.Decompose(pending.Event);
+ 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
// rows reconciled in from other nodes via the same writer);
// 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
// node), the row's SourceNode stays NULL — operators see
// "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;
- pSourceInstanceId.Value = (object?)e.SourceInstanceId ?? DBNull.Value;
- pSourceScript.Value = (object?)e.SourceScript ?? DBNull.Value;
- pActor.Value = (object?)e.Actor ?? DBNull.Value;
- pTarget.Value = (object?)e.Target ?? DBNull.Value;
- pStatus.Value = e.Status.ToString();
- pHttpStatus.Value = (object?)e.HttpStatus ?? DBNull.Value;
- pDurationMs.Value = (object?)e.DurationMs ?? DBNull.Value;
- pErrorMessage.Value = (object?)e.ErrorMessage ?? DBNull.Value;
- pErrorDetail.Value = (object?)e.ErrorDetail ?? DBNull.Value;
- pRequestSummary.Value = (object?)e.RequestSummary ?? DBNull.Value;
- pResponseSummary.Value = (object?)e.ResponseSummary ?? DBNull.Value;
- pPayloadTruncated.Value = e.PayloadTruncated ? 1 : 0;
- pExtra.Value = (object?)e.Extra ?? DBNull.Value;
- pForwardState.Value = (e.ForwardState ?? AuditForwardState.Pending).ToString();
- pExecutionId.Value = (object?)e.ExecutionId?.ToString() ?? DBNull.Value;
- pParentExecutionId.Value = (object?)e.ParentExecutionId?.ToString() ?? DBNull.Value;
+ pSourceInstanceId.Value = (object?)r.SourceInstanceId ?? DBNull.Value;
+ pSourceScript.Value = (object?)r.SourceScript ?? DBNull.Value;
+ pActor.Value = (object?)r.Actor ?? DBNull.Value;
+ pTarget.Value = (object?)r.Target ?? DBNull.Value;
+ pStatus.Value = r.Status.ToString();
+ pHttpStatus.Value = (object?)r.HttpStatus ?? DBNull.Value;
+ pDurationMs.Value = (object?)r.DurationMs ?? DBNull.Value;
+ pErrorMessage.Value = (object?)r.ErrorMessage ?? DBNull.Value;
+ pErrorDetail.Value = (object?)r.ErrorDetail ?? DBNull.Value;
+ pRequestSummary.Value = (object?)r.RequestSummary ?? DBNull.Value;
+ pResponseSummary.Value = (object?)r.ResponseSummary ?? DBNull.Value;
+ pPayloadTruncated.Value = r.PayloadTruncated ? 1 : 0;
+ pExtra.Value = (object?)r.Extra ?? DBNull.Value;
+ pForwardState.Value = pending.ForwardState.ToString();
+ pExecutionId.Value = (object?)r.ExecutionId?.ToString() ?? DBNull.Value;
+ pParentExecutionId.Value = (object?)r.ParentExecutionId?.ToString() ?? DBNull.Value;
try
{
@@ -405,7 +407,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// recorded under the first writer's payload.
_logger.LogDebug(ex,
"Duplicate EventId {EventId} swallowed by SqliteAuditWriter",
- e.EventId);
+ r.EventId);
pending.Completion.TrySetResult();
}
}
@@ -788,34 +790,36 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
private static AuditEvent MapRow(SqliteDataReader reader)
{
- return new AuditEvent
- {
- EventId = Guid.Parse(reader.GetString(0)),
- OccurredAtUtc = DateTime.Parse(reader.GetString(1),
+ // C3 transitional shim: recompose the canonical record from the 24
+ // columns. The ForwardState column (ordinal 20) is read for the
+ // schema's sake but NOT placed on the canonical record — it stays a
+ // 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.DateTimeStyles.RoundtripKind),
- Channel = Enum.Parse(reader.GetString(2)),
- Kind = Enum.Parse(reader.GetString(3)),
- CorrelationId = reader.IsDBNull(4) ? null : Guid.Parse(reader.GetString(4)),
- SourceSiteId = reader.IsDBNull(5) ? null : reader.GetString(5),
- SourceNode = reader.IsDBNull(6) ? null : reader.GetString(6),
- SourceInstanceId = reader.IsDBNull(7) ? null : reader.GetString(7),
- SourceScript = reader.IsDBNull(8) ? null : reader.GetString(8),
- Actor = reader.IsDBNull(9) ? null : reader.GetString(9),
- Target = reader.IsDBNull(10) ? null : reader.GetString(10),
- Status = Enum.Parse(reader.GetString(11)),
- HttpStatus = reader.IsDBNull(12) ? null : reader.GetInt32(12),
- DurationMs = reader.IsDBNull(13) ? null : reader.GetInt32(13),
- ErrorMessage = reader.IsDBNull(14) ? null : reader.GetString(14),
- ErrorDetail = reader.IsDBNull(15) ? null : reader.GetString(15),
- RequestSummary = reader.IsDBNull(16) ? null : reader.GetString(16),
- ResponseSummary = reader.IsDBNull(17) ? null : reader.GetString(17),
- PayloadTruncated = reader.GetInt32(18) != 0,
- Extra = reader.IsDBNull(19) ? null : reader.GetString(19),
- ForwardState = Enum.Parse(reader.GetString(20)),
- ExecutionId = reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)),
- ParentExecutionId = reader.IsDBNull(22) ? null : Guid.Parse(reader.GetString(22)),
- };
+ IngestedAtUtc: null,
+ Channel: Enum.Parse(reader.GetString(2)),
+ Kind: Enum.Parse(reader.GetString(3)),
+ Status: Enum.Parse(reader.GetString(11)),
+ CorrelationId: reader.IsDBNull(4) ? null : Guid.Parse(reader.GetString(4)),
+ ExecutionId: reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)),
+ ParentExecutionId: reader.IsDBNull(22) ? null : Guid.Parse(reader.GetString(22)),
+ SourceSiteId: reader.IsDBNull(5) ? null : reader.GetString(5),
+ SourceNode: reader.IsDBNull(6) ? null : reader.GetString(6),
+ SourceInstanceId: reader.IsDBNull(7) ? null : reader.GetString(7),
+ SourceScript: reader.IsDBNull(8) ? null : reader.GetString(8),
+ Actor: reader.IsDBNull(9) ? null : reader.GetString(9),
+ Target: reader.IsDBNull(10) ? null : reader.GetString(10),
+ HttpStatus: reader.IsDBNull(12) ? null : reader.GetInt32(12),
+ DurationMs: reader.IsDBNull(13) ? null : reader.GetInt32(13),
+ ErrorMessage: reader.IsDBNull(14) ? null : reader.GetString(14),
+ ErrorDetail: reader.IsDBNull(15) ? null : reader.GetString(15),
+ RequestSummary: reader.IsDBNull(16) ? null : reader.GetString(16),
+ ResponseSummary: reader.IsDBNull(17) ? null : reader.GetString(17),
+ PayloadTruncated: reader.GetInt32(18) != 0,
+ Extra: reader.IsDBNull(19) ? null : reader.GetString(19)));
}
///
@@ -898,15 +902,19 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
private sealed class PendingAuditEvent
{
/// Initializes a new instance of the PendingAuditEvent class.
- /// The audit event to persist.
- public PendingAuditEvent(AuditEvent evt)
+ /// The canonical audit event to persist.
+ /// Site-local forwarding state stored alongside the canonical row (C3 shim — not a canonical field).
+ public PendingAuditEvent(AuditEvent evt, AuditForwardState forwardState)
{
Event = evt;
+ ForwardState = forwardState;
Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
}
- /// The audit event to persist.
+ /// The canonical audit event to persist.
public AuditEvent Event { get; }
+ /// Site-local forwarding state for this row (C3 shim — bound to the ForwardState column).
+ public AuditForwardState ForwardState { get; }
/// Task completion source for write completion signaling.
public TaskCompletionSource Completion { get; }
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs
index cf404e88..10a6c251 100644
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs
@@ -1,8 +1,8 @@
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.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
@@ -141,37 +141,33 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
var channel = ChannelStringToEnum(context.Channel);
return new CachedCallTelemetry(
- Audit: new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
- Channel = channel,
- Kind = kind,
- CorrelationId = context.TrackedOperationId.Value,
+ Audit: ScadaBridgeAuditEventFactory.Create(
+ channel: channel,
+ kind: kind,
+ status: status,
+ occurredAtUtc: DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
+ target: context.Target,
+ correlationId: context.TrackedOperationId.Value,
// Audit Log #23 (ExecutionId Task 4): the originating script
// execution's per-run correlation id, threaded through the S&F
// buffer; null on rows buffered before Task 4 (back-compat).
- ExecutionId = context.ExecutionId,
+ executionId: context.ExecutionId,
// Audit Log #23 (ParentExecutionId Task 6): the spawning
// inbound-API request's ExecutionId, threaded through the S&F
// buffer alongside ExecutionId so the retry-loop cached rows
// correlate back to the cross-execution chain. Null for a
// non-routed run and on rows buffered before Task 6.
- ParentExecutionId = context.ParentExecutionId,
- SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
- SourceInstanceId = context.SourceInstanceId,
+ parentExecutionId: context.ParentExecutionId,
+ sourceSiteId: string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
+ sourceInstanceId: context.SourceInstanceId,
// Audit Log #23 (ExecutionId Task 4): SourceScript is now
// threaded through the S&F buffer alongside ExecutionId — the
// retry-loop cached rows carry the same provenance the
// script-side cached rows do. Null on pre-Task-4 buffered rows.
- SourceScript = context.SourceScript,
- Target = context.Target,
- Status = status,
- HttpStatus = httpStatus,
- DurationMs = context.DurationMs,
- ErrorMessage = lastError,
- ForwardState = AuditForwardState.Pending,
- },
+ sourceScript: context.SourceScript,
+ httpStatus: httpStatus,
+ durationMs: context.DurationMs,
+ errorMessage: lastError),
Operational: new SiteCallOperational(
TrackedOperationId: context.TrackedOperationId,
Channel: context.Channel,
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/CachedCallTelemetryForwarder.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/CachedCallTelemetryForwarder.cs
index 90b61167..e4562406 100644
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/CachedCallTelemetryForwarder.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/CachedCallTelemetryForwarder.cs
@@ -1,9 +1,9 @@
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.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
@@ -111,9 +111,11 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
// FallbackAuditWriter) handles transient writer failures upstream;
// a throw bubbling up here means the writer's own swallow contract
// 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,
"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;
}
+ // 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
{
- switch (telemetry.Audit.Kind)
+ switch (audit.Kind)
{
case AuditKind.CachedSubmit:
// Enqueue — insert-if-not-exists with the operational
@@ -144,8 +149,8 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
telemetry.Operational.TrackedOperationId,
telemetry.Operational.Channel,
telemetry.Operational.Target,
- telemetry.Audit.SourceInstanceId,
- telemetry.Audit.SourceScript,
+ audit.SourceInstanceId,
+ audit.SourceScript,
sourceNode: _nodeIdentity?.NodeName,
ct).ConfigureAwait(false);
break;
@@ -180,7 +185,7 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
// forwarder.
_logger.LogWarning(
"CachedCallTelemetryForwarder: unexpected audit kind {Kind} on tracking emission for EventId {EventId}",
- telemetry.Audit.Kind, telemetry.Audit.EventId);
+ audit.Kind, audit.EventId);
break;
}
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/ClusterClientSiteAuditClient.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/ClusterClientSiteAuditClient.cs
index a53df7ea..0cc880e6 100644
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/ClusterClientSiteAuditClient.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/ClusterClientSiteAuditClient.cs
@@ -1,5 +1,5 @@
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.Communication.Grpc;
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs
index 67d2a511..ed4c0f71 100644
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs
@@ -2,10 +2,11 @@ using Akka.Actor;
using Google.Protobuf.WellKnownTypes;
using Microsoft.Extensions.Logging;
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.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
@@ -259,8 +260,8 @@ public class SiteAuditTelemetryActor : ReceiveActor
// row stays Pending (still not in emittedEventIds) and
// central reconciliation will pick it up.
_logger.LogWarning(
- "Cached-telemetry drain: audit row {EventId} ({Kind}) has no CorrelationId; skipping.",
- auditRow.EventId, auditRow.Kind);
+ "Cached-telemetry drain: audit row {EventId} ({Action}) has no CorrelationId; skipping.",
+ auditRow.EventId, auditRow.Action);
continue;
}
@@ -363,10 +364,13 @@ public class SiteAuditTelemetryActor : ReceiveActor
private static CachedTelemetryPacket BuildCachedPacket(
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
// by SiteCallOperational + CachedCallLifecycleBridge.BuildPacket.
- var channelString = auditRow.Channel.ToString();
+ var channelString = audit.Channel.ToString();
var target = auditRow.Target ?? snapshot.TargetSummary ?? string.Empty;
var operationalDto = new SiteCallOperationalDto
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditDrilldownDrawer.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditDrilldownDrawer.razor
index 570100ff..16c03826 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditDrilldownDrawer.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditDrilldownDrawer.razor
@@ -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
@* Audit Log drilldown drawer (#23 M7 Bundle C / M7-T4..T8).
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditDrilldownDrawer.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditDrilldownDrawer.razor.cs
index 20ccd283..725cbf4f 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditDrilldownDrawer.razor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditDrilldownDrawer.razor.cs
@@ -1,11 +1,11 @@
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;
///
/// Child component for the central Audit Log page (#23 M7 Bundle C / M7-T4..T8).
-/// Renders one in a right-side off-canvas drawer.
+/// Renders one in a right-side off-canvas drawer.
/// The drawer owns only the offcanvas chrome — backdrop, header, and the two
/// Close buttons; the single-row detail body (read-only fields, conditional
/// 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
/// page uses this together with to drive visibility.
///
- [Parameter] public AuditEvent? Event { get; set; }
+ [Parameter] public AuditEventView? Event { get; set; }
///
/// True when the host wants the drawer visible. We deliberately keep
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditEventDetail.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditEventDetail.razor
index 35efa3c2..4f2de256 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditEventDetail.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditEventDetail.razor
@@ -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
@* Reusable single-AuditEvent detail body (#23 M7 Bundle C / M7-T4..T8).
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditEventDetail.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditEventDetail.razor.cs
index 2ff2f001..818677d5 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditEventDetail.razor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditEventDetail.razor.cs
@@ -3,7 +3,7 @@ using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Components;
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;
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)
/// only mounts this component once it has a row to show.
///
- [Parameter, EditorRequired] public AuditEvent Event { get; set; } = null!;
+ [Parameter, EditorRequired] public AuditEventView Event { get; set; } = null!;
private const string RedactionSentinel = "";
private const string RedactorErrorSentinel = "";
@@ -303,7 +303,7 @@ public partial class AuditEventDetail
/// outbound audit rows — the audit pipeline does not always capture
/// the verb explicitly.
///
- private static string BuildCurlCommand(AuditEvent ev)
+ private static string BuildCurlCommand(AuditEventView ev)
{
var sb = new StringBuilder();
sb.Append("curl");
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditResultsGrid.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditResultsGrid.razor
index da0dc749..a37c5b57 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditResultsGrid.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditResultsGrid.razor
@@ -1,6 +1,5 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@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.Enums
@inject IAuditLogQueryService QueryService
@@ -103,7 +102,7 @@
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)
{
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditResultsGrid.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditResultsGrid.razor.cs
index 08c4bcc0..7f3174f4 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditResultsGrid.razor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditResultsGrid.razor.cs
@@ -1,7 +1,7 @@
using System.Text.Json;
using Microsoft.AspNetCore.Components;
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.Enums;
@@ -61,7 +61,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
private const string ColumnOrderStorageKey = "columnOrder";
private const string ColumnWidthsStorageKey = "columnWidths";
- private readonly List _rows = new();
+ private readonly List _rows = new();
private int _pageNumber = 1;
private bool _loading;
private string? _error;
@@ -109,9 +109,9 @@ public partial class AuditResultsGrid : IAsyncDisposable
///
/// Raised when the user clicks a row. Bundle C wires this to the drilldown
- /// drawer. The event payload is the full .
+ /// drawer. The event payload is the full .
///
- [Parameter] public EventCallback OnRowSelected { get; set; }
+ [Parameter] public EventCallback OnRowSelected { get; set; }
// Effective page size used when paging. Mirrors PageSize but bounded > 0.
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)
{
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/ExecutionDetailModal.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/ExecutionDetailModal.razor
index baa9a227..1850469a 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/ExecutionDetailModal.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/ExecutionDetailModal.razor
@@ -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).
Opened from an execution-tree node double-click. Given an ExecutionId it
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/ExecutionDetailModal.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/ExecutionDetailModal.razor.cs
index 2313fbed..d00370f2 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/ExecutionDetailModal.razor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/ExecutionDetailModal.razor.cs
@@ -2,7 +2,6 @@ using System.Globalization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
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.Enums;
@@ -61,10 +60,10 @@ public partial class ExecutionDetailModal
[Parameter] public EventCallback OnClose { get; set; }
// The loaded rows for the current execution; empty until a load completes.
- private IReadOnlyList _rows = Array.Empty();
+ private IReadOnlyList _rows = Array.Empty();
// The row whose detail is shown; null = list view.
- private AuditEvent? _selectedRow;
+ private AuditEventView? _selectedRow;
private bool _loading;
private string? _error;
@@ -103,7 +102,7 @@ public partial class ExecutionDetailModal
_loading = true;
_error = null;
_selectedRow = null;
- _rows = Array.Empty();
+ _rows = Array.Empty();
if (ExecutionId is null)
{
@@ -135,7 +134,7 @@ public partial class ExecutionDetailModal
// degrades the modal to an inline error banner rather than killing
// the SignalR circuit. Never rethrow.
_error = $"Could not load this execution's audit rows: {ex.Message}";
- _rows = Array.Empty();
+ _rows = Array.Empty();
_selectedRow = null;
}
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;
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/AuditLogPage.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/AuditLogPage.razor
index 8178b9df..667467fc 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/AuditLogPage.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/AuditLogPage.razor
@@ -2,7 +2,6 @@
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit
@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.Security
@inject IAuditLogQueryService AuditLogQueryService
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs
index 8b9575f0..4baa1cab 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs
@@ -2,7 +2,7 @@ using System.Globalization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
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.Enums;
@@ -50,7 +50,7 @@ public partial class AuditLogPage : IDisposable
[Inject] private NavigationManager Navigation { get; set; } = null!;
private AuditLogQueryFilter? _currentFilter;
- private AuditEvent? _selectedEvent;
+ private AuditEventView? _selectedEvent;
private bool _drawerOpen;
private string? _initialInstanceSearch;
@@ -222,7 +222,7 @@ public partial class AuditLogPage : IDisposable
_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
// the selected row and open the drilldown drawer — the drawer is fully
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AuditEventView.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AuditEventView.cs
new file mode 100644
index 00000000..2aada8e4
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AuditEventView.cs
@@ -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;
+
+///
+/// Flattened, typed view of a canonical 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 ) so the
+/// existing razor bindings (row.Channel, Event.Status, evt.RequestSummary,
+/// …) keep working against typed properties rather than parsing DetailsJson inline.
+///
+///
+/// This is presentation-only: it carries the same field surface the bespoke
+/// Commons.Entities.Audit.AuditEvent exposed before C3. ForwardState is always
+/// null on the central read path (it is site-storage-only and not carried on canonical rows).
+///
+public sealed record AuditEventView
+{
+ /// Idempotency key.
+ public Guid EventId { get; init; }
+ /// UTC timestamp when the audited action occurred.
+ public DateTime OccurredAtUtc { get; init; }
+ /// UTC ingest timestamp (central-set); null until ingest.
+ public DateTime? IngestedAtUtc { get; init; }
+ /// Trust-boundary channel.
+ public AuditChannel Channel { get; init; }
+ /// Specific event kind.
+ public AuditKind Kind { get; init; }
+ /// Per-operation correlation id.
+ public Guid? CorrelationId { get; init; }
+ /// Originating execution id.
+ public Guid? ExecutionId { get; init; }
+ /// Spawning execution id; null for top-level runs.
+ public Guid? ParentExecutionId { get; init; }
+ /// Site id where the action originated.
+ public string? SourceSiteId { get; init; }
+ /// Cluster node that emitted the event.
+ public string? SourceNode { get; init; }
+ /// Instance id where the action originated.
+ public string? SourceInstanceId { get; init; }
+ /// Script that initiated the action.
+ public string? SourceScript { get; init; }
+ /// Authenticated actor.
+ public string? Actor { get; init; }
+ /// Target of the action.
+ public string? Target { get; init; }
+ /// Lifecycle status.
+ public AuditStatus Status { get; init; }
+ /// HTTP status code where applicable.
+ public int? HttpStatus { get; init; }
+ /// Duration of the action in ms.
+ public int? DurationMs { get; init; }
+ /// Human-readable error summary.
+ public string? ErrorMessage { get; init; }
+ /// Verbose error detail.
+ public string? ErrorDetail { get; init; }
+ /// Truncated/redacted request summary.
+ public string? RequestSummary { get; init; }
+ /// Truncated/redacted response summary.
+ public string? ResponseSummary { get; init; }
+ /// True when summaries were truncated.
+ public bool PayloadTruncated { get; init; }
+ /// Free-form JSON extension.
+ public string? Extra { get; init; }
+ /// Site-local forwarding state; always null on the central read path.
+ public AuditForwardState? ForwardState { get; init; }
+
+ ///
+ /// Decomposes a canonical into a flat view for the UI.
+ ///
+ 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,
+ };
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AuditLogExportService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AuditLogExportService.cs
index a513a438..ae98f237 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AuditLogExportService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AuditLogExportService.cs
@@ -1,6 +1,5 @@
using System.Globalization;
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.Types.Audit;
@@ -121,7 +120,7 @@ public sealed class AuditLogExportService : IAuditLogExportService
{
break;
}
- await writer.WriteLineAsync(FormatCsvRow(evt));
+ await writer.WriteLineAsync(FormatCsvRow(AuditEventView.From(evt)));
written++;
}
@@ -140,7 +139,9 @@ public sealed class AuditLogExportService : IAuditLogExportService
var last = page[^1];
cursor = new AuditLogPaging(
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);
}
@@ -169,13 +170,13 @@ public sealed class AuditLogExportService : IAuditLogExportService
"ResponseSummary,PayloadTruncated,Extra,ForwardState";
///
- /// Serialises one as a CSV row (no trailing newline).
+ /// Serialises one as a CSV row (no trailing newline).
/// Each nullable column renders as the empty string when null; non-null
/// scalars use invariant culture so an export taken on one locale parses
/// cleanly on another.
///
- /// The audit event to format as a CSV row.
- internal static string FormatCsvRow(AuditEvent evt)
+ /// The audit event view to format as a CSV row.
+ internal static string FormatCsvRow(AuditEventView evt)
{
var sb = new StringBuilder(256);
AppendField(sb, evt.EventId.ToString(), first: true);
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AuditLogQueryService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AuditLogQueryService.cs
index 245ee2e3..80a6a43d 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AuditLogQueryService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AuditLogQueryService.cs
@@ -1,5 +1,4 @@
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.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -93,7 +92,7 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
public int DefaultPageSize => 100;
///
- public async Task> QueryAsync(
+ public async Task> QueryAsync(
AuditLogQueryFilter filter,
AuditLogPaging? paging = null,
CancellationToken ct = default)
@@ -101,17 +100,22 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
ArgumentNullException.ThrowIfNull(filter);
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.
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
// page's auto-load never shares the circuit-scoped context.
await using var scope = _scopeFactory!.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService();
- return await repository.QueryAsync(filter, effective, ct);
+ var result = await repository.QueryAsync(filter, effective, ct);
+ return result.Select(AuditEventView.From).ToList();
}
///
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IAuditLogQueryService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IAuditLogQueryService.cs
index 32fa5167..8bc87f6d 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IAuditLogQueryService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IAuditLogQueryService.cs
@@ -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.Audit;
@@ -18,13 +17,18 @@ public interface IAuditLogQueryService
/// is null, defaults to
/// rows with no cursor (first page). The repository orders by
/// (OccurredAtUtc DESC, EventId DESC); pass the last row's
- /// +
+ /// +
/// back as the cursor for the next page.
///
+ ///
+ /// C3 (Task 2.5): the repository seam returns the canonical
+ /// ZB.MOM.WW.Audit.AuditEvent; this facade decomposes each row into a flat
+ /// so the audit pages keep binding to typed properties.
+ ///
/// Filter criteria applied to the audit log query.
/// Optional paging cursor; defaults to first page when null.
/// Cancellation token.
- Task> QueryAsync(
+ Task> QueryAsync(
AuditLogQueryFilter filter,
AuditLogPaging? paging = null,
CancellationToken ct = default);
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Audit/AuditEvent.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Audit/AuditEvent.cs
deleted file mode 100644
index 0f4c9b96..00000000
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Audit/AuditEvent.cs
+++ /dev/null
@@ -1,137 +0,0 @@
-using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
-
-namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
-
-///
-/// Single source of truth for AuditLog (#23) rows. Central rows leave ForwardState null;
-/// site rows leave IngestedAtUtc null until ingest. Append-only.
-///
-///
-/// All *Utc-suffixed properties on this record are
-/// invariantly UTC ("All timestamps are UTC throughout the system." — CLAUDE.md).
-/// Their init-setters call
-/// to force on assignment, so a value built from a
-/// DateTime literal or re-hydrated from a SQL Server datetime2 column
-/// (which strips the Kind flag on the wire) cannot leak downstream as
-/// or be silently re-interpreted as local
-/// time. The unrelated
-/// surface uses for the same UTC guarantee; this
-/// entity stays on to match the partitioned SQL Server
-/// datetime2 column shape required by the AuditLog table.
-///
-public sealed record AuditEvent
-{
- /// Idempotency key; uniquely identifies one audit lifecycle event.
- public Guid EventId { get; init; }
-
- ///
- /// 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 on assignment via
- /// , so any
- /// construction path that supplies a value with
- /// (e.g. a DateTime literal, JSON deserialisation, or a SQL Server
- /// datetime2 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 Kind flag, it cannot re-interpret a local-time value.
- ///
- public DateTime OccurredAtUtc
- {
- get => _occurredAtUtc;
- init => _occurredAtUtc = DateTime.SpecifyKind(value, DateTimeKind.Utc);
- }
- private readonly DateTime _occurredAtUtc;
-
- ///
- /// 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
- /// on assignment, matching
- /// 's contract.
- ///
- public DateTime? IngestedAtUtc
- {
- get => _ingestedAtUtc;
- init => _ingestedAtUtc = value.HasValue
- ? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc)
- : null;
- }
- private readonly DateTime? _ingestedAtUtc;
-
- /// Trust-boundary channel the audited action crossed.
- public AuditChannel Channel { get; init; }
-
- /// Specific event kind within the channel (see alog.md §4).
- public AuditKind Kind { get; init; }
-
- /// Correlation id linking related audit rows (e.g. the cached-op lifecycle).
- public Guid? CorrelationId { get; init; }
-
- ///
- /// Id of the originating script execution / inbound request — the universal
- /// per-run correlation value, distinct from (which
- /// is the per-operation lifecycle id).
- ///
- public Guid? ExecutionId { get; init; }
-
- ///
- /// 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.
- ///
- public Guid? ParentExecutionId { get; init; }
-
- /// Site id where the action originated; null for central-direct events.
- public string? SourceSiteId { get; init; }
-
- ///
- /// The cluster node on which the event was emitted — `node-a` / `node-b` for
- /// site rows (qualified by ), `central-a` / `central-b`
- /// for central-originated rows. Stamped by the writing node from
- /// INodeIdentityProvider; nullable so reconciled rows from a node that
- /// has since been retired don't block ingest.
- ///
- public string? SourceNode { get; init; }
-
- /// Instance id where the action originated, when applicable.
- public string? SourceInstanceId { get; init; }
-
- /// Script that initiated the action (script trust boundary), when applicable.
- public string? SourceScript { get; init; }
-
- /// Authenticated actor for inbound paths (API key name, user, etc.).
- public string? Actor { get; init; }
-
- /// Target of the action: external system name, db connection name, list name, or inbound method.
- public string? Target { get; init; }
-
- /// Lifecycle status of this row.
- public AuditStatus Status { get; init; }
-
- /// HTTP status code where applicable (outbound API + inbound API).
- public int? HttpStatus { get; init; }
-
- /// Duration of the audited action in milliseconds, when measurable.
- public int? DurationMs { get; init; }
-
- /// Human-readable error summary on failure rows.
- public string? ErrorMessage { get; init; }
-
- /// Verbose error detail (stack/exception) on failure rows.
- public string? ErrorDetail { get; init; }
-
- /// Truncated/redacted request summary; capped per AuditLogOptions.
- public string? RequestSummary { get; init; }
-
- /// Truncated/redacted response summary; capped per AuditLogOptions.
- public string? ResponseSummary { get; init; }
-
- /// True when Request/Response summaries were truncated to the payload cap.
- public bool PayloadTruncated { get; init; }
-
- /// Free-form JSON extension column for channel-specific extras.
- public string? Extra { get; init; }
-
- /// Site-local forwarding state; null on central rows.
- public AuditForwardState? ForwardState { get; init; }
-}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs
index 113ebbe3..ffefb926 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs
@@ -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.Audit;
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/IAuditWriter.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/IAuditWriter.cs
index a340f881..c49f07ca 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/IAuditWriter.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/IAuditWriter.cs
@@ -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;
@@ -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.
/// Failures must NEVER abort the user-facing action.
///
+///
+/// C3 (Task 2.5): the event type is the canonical .
+/// The local seam is retained (rather than collapsed onto ZB.MOM.WW.Audit.IAuditWriter)
+/// so it stays a distinct DI binding from and so the many
+/// existing site/central implementations and test fakes keep their identity.
+///
public interface IAuditWriter
{
///
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/ICentralAuditWriter.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/ICentralAuditWriter.cs
index d593623d..c30d41e5 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/ICentralAuditWriter.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/ICentralAuditWriter.cs
@@ -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;
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/ISiteAuditQueue.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/ISiteAuditQueue.cs
index 559a808c..b8df0cc0 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/ISiteAuditQueue.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/ISiteAuditQueue.cs
@@ -1,4 +1,4 @@
-using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
+using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
@@ -34,7 +34,7 @@ public interface ISiteAuditQueue
/// will yield the same rows again.
///
///
- /// AuditLog-001: cached-lifecycle s
+ /// AuditLog-001: cached-lifecycle audit kinds
/// (,
/// ,
/// ,
@@ -52,7 +52,7 @@ public interface ISiteAuditQueue
///
/// AuditLog-001: returns up to rows in
///
- /// whose belongs to the cached-call lifecycle
+ /// whose audit kind belongs to the cached-call lifecycle
/// vocabulary (,
/// ,
/// ,
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/IngestAuditEventsCommand.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/IngestAuditEventsCommand.cs
index 8ec132df..12ce952c 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/IngestAuditEventsCommand.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/IngestAuditEventsCommand.cs
@@ -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;
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/IngestCachedTelemetryCommand.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/IngestCachedTelemetryCommand.cs
index 15ab0f7f..6dabc987 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/IngestCachedTelemetryCommand.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/IngestCachedTelemetryCommand.cs
@@ -1,3 +1,4 @@
+using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Integration/AuditTelemetryEnvelope.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Integration/AuditTelemetryEnvelope.cs
index bf461a9e..a7b64d68 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Integration/AuditTelemetryEnvelope.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Integration/AuditTelemetryEnvelope.cs
@@ -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;
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Integration/CachedCallTelemetry.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Integration/CachedCallTelemetry.cs
index 185c5a76..dba53d23 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Integration/CachedCallTelemetry.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Integration/CachedCallTelemetry.cs
@@ -1,4 +1,4 @@
-using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
+using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Integration/PullAuditEventsResponse.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Integration/PullAuditEventsResponse.cs
index a2ee242c..9308dbaf 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Integration/PullAuditEventsResponse.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Integration/PullAuditEventsResponse.cs
@@ -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;
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditRowProjection.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditRowProjection.cs
new file mode 100644
index 00000000..4f0298a7
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditRowProjection.cs
@@ -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;
+
+///
+/// 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
+/// into the typed domain values the
+/// columns expect (Channel/Kind/Status enums + the
+/// fields) and recomposes a canonical record from those column values.
+///
+///
+///
+/// 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 DetailsJson; 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.
+///
+///
+/// ForwardState 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 DetailsJson and never on a central row.
+///
+///
+public static class AuditRowProjection
+{
+ ///
+ /// The decomposed domain view of a canonical — the
+ /// values the 24 storage columns expect. Built by from
+ /// the canonical top-level fields plus the bag.
+ ///
+ 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);
+
+ ///
+ /// Decomposes a canonical record into the typed column values. Channel/Kind/Status
+ /// come from DetailsJson (the strings written by
+ /// ); a missing/unparseable discriminator
+ /// falls back to the first enum member (defensive — production rows always carry them).
+ ///
+ 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);
+ }
+
+ ///
+ /// Recomposes a canonical from the typed column values read
+ /// back from storage. The inverse of : Action/Category/Outcome
+ /// are rebuilt via the field builders / outcome projector, and every domain field is
+ /// re-serialized into DetailsJson via .
+ ///
+ 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),
+ };
+ }
+
+ ///
+ /// Returns a copy of with the central-side ingest timestamp
+ /// stamped into its DetailsJson ().
+ /// C3 transitional shim: IngestedAtUtc 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.
+ ///
+ 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(string? value, TEnum fallback) where TEnum : struct, Enum
+ => !string.IsNullOrEmpty(value) && Enum.TryParse(value, ignoreCase: false, out var parsed)
+ ? parsed
+ : fallback;
+}
+
+///
+/// Convenience extension that decomposes a canonical into its
+/// typed 24-field view. Lets callers
+/// (and tests) read the ScadaBridge domain fields — Channel/Kind/Status + the DetailsJson
+/// fields — as typed properties off a canonical row.
+///
+public static class AuditEventRowExtensions
+{
+ /// Decomposes this canonical record into its typed 24-field view.
+ public static AuditRowProjection.AuditRowValues AsRow(this AuditEvent evt)
+ => AuditRowProjection.Decompose(evt);
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/ScadaBridgeAuditEventFactory.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/ScadaBridgeAuditEventFactory.cs
new file mode 100644
index 00000000..75b34112
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/ScadaBridgeAuditEventFactory.cs
@@ -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;
+
+///
+/// Single construction point for the canonical
+/// from ScadaBridge's domain vocabulary. Every emit site builds its row through
+/// so the canonical-field mapping (Channel/Kind/Status →
+/// Action/Category/Outcome, every other domain field →
+/// inside ) is applied
+/// identically everywhere — no per-site drift.
+///
+///
+/// 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 DetailsJson via
+/// .
+/// Mapping (see Task 2.5 spec):
+///
+/// - Action = (channel, kind).
+/// - Category = (channel) (= channel name).
+/// - Outcome = (status, kind).
+/// - DetailsJson carries Channel/Kind/Status (as strings) + every other
+/// ScadaBridge domain field. ForwardState is NOT a DetailsJson field — it is
+/// a site-storage-only concern handled by the site SQLite shim.
+///
+///
+///
+public static class ScadaBridgeAuditEventFactory
+{
+ ///
+ /// Builds the canonical for one ScadaBridge
+ /// audit row. //
+ /// drive the canonical Action/Category/Outcome and are also recorded (as strings) in
+ /// DetailsJson; all remaining ScadaBridge domain fields are carried in
+ /// DetailsJson too.
+ ///
+ /// Trust-boundary channel the audited action crossed.
+ /// Specific event kind within the channel.
+ /// Lifecycle status of this row.
+ /// Idempotency key. Defaults to a fresh when omitted.
+ /// When the action occurred (UTC). Defaults to when omitted.
+ /// Authenticated actor for inbound paths (API key name, user, "system", etc.).
+ /// Target of the action (external system, db connection, list name, inbound method).
+ /// Cluster node that emitted the event (top-level canonical field).
+ /// Per-operation lifecycle correlation id (top-level canonical field).
+ /// Originating script-execution / inbound-request id (DetailsJson).
+ /// Spawning execution's id (DetailsJson).
+ /// Site id where the action originated (DetailsJson).
+ /// Instance id where the action originated (DetailsJson).
+ /// Script that initiated the action (DetailsJson).
+ /// HTTP status code where applicable (DetailsJson).
+ /// Duration of the audited action in ms (DetailsJson).
+ /// Human-readable error summary on failure rows (DetailsJson).
+ /// Verbose error detail (stack/exception) on failure rows (DetailsJson).
+ /// Truncated/redacted request summary (DetailsJson).
+ /// Truncated/redacted response summary (DetailsJson).
+ /// True when summaries were truncated to the payload cap (DetailsJson).
+ /// Free-form JSON extension for channel-specific extras (DetailsJson).
+ /// UTC ingest timestamp (central-set; DetailsJson).
+ 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),
+ };
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/AuditEventDtoMapper.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/AuditEventDtoMapper.cs
index ee4918d6..071fca8f 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/AuditEventDtoMapper.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/AuditEventDtoMapper.cs
@@ -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 Timestamp = Google.Protobuf.WellKnownTypes.Timestamp;
@@ -41,38 +42,44 @@ public static class AuditEventDtoMapper
{
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
{
- EventId = evt.EventId.ToString(),
- OccurredAtUtc = Timestamp.FromDateTime(EnsureUtc(evt.OccurredAtUtc)),
- Channel = evt.Channel.ToString(),
- Kind = evt.Kind.ToString(),
- CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty,
- ExecutionId = evt.ExecutionId?.ToString() ?? string.Empty,
- ParentExecutionId = evt.ParentExecutionId?.ToString() ?? string.Empty,
- SourceSiteId = evt.SourceSiteId ?? string.Empty,
- SourceNode = evt.SourceNode ?? string.Empty,
- SourceInstanceId = evt.SourceInstanceId ?? string.Empty,
- SourceScript = evt.SourceScript ?? string.Empty,
- Actor = evt.Actor ?? string.Empty,
- Target = evt.Target ?? string.Empty,
- Status = evt.Status.ToString(),
- ErrorMessage = evt.ErrorMessage ?? string.Empty,
- ErrorDetail = evt.ErrorDetail ?? string.Empty,
- RequestSummary = evt.RequestSummary ?? string.Empty,
- ResponseSummary = evt.ResponseSummary ?? string.Empty,
- PayloadTruncated = evt.PayloadTruncated,
- Extra = evt.Extra ?? string.Empty
+ EventId = r.EventId.ToString(),
+ OccurredAtUtc = Timestamp.FromDateTime(EnsureUtc(r.OccurredAtUtc)),
+ Channel = r.Channel.ToString(),
+ Kind = r.Kind.ToString(),
+ CorrelationId = r.CorrelationId?.ToString() ?? string.Empty,
+ ExecutionId = r.ExecutionId?.ToString() ?? string.Empty,
+ ParentExecutionId = r.ParentExecutionId?.ToString() ?? string.Empty,
+ SourceSiteId = r.SourceSiteId ?? string.Empty,
+ SourceNode = r.SourceNode ?? string.Empty,
+ SourceInstanceId = r.SourceInstanceId ?? string.Empty,
+ SourceScript = r.SourceScript ?? string.Empty,
+ Actor = r.Actor ?? string.Empty,
+ Target = r.Target ?? string.Empty,
+ Status = r.Status.ToString(),
+ ErrorMessage = r.ErrorMessage ?? string.Empty,
+ ErrorDetail = r.ErrorDetail ?? string.Empty,
+ RequestSummary = r.RequestSummary ?? string.Empty,
+ ResponseSummary = r.ResponseSummary ?? string.Empty,
+ PayloadTruncated = r.PayloadTruncated,
+ 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;
@@ -89,33 +96,35 @@ public static class AuditEventDtoMapper
{
ArgumentNullException.ThrowIfNull(dto);
- return new AuditEvent
- {
- EventId = Guid.Parse(dto.EventId),
- OccurredAtUtc = DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc),
- IngestedAtUtc = null,
- Channel = Enum.Parse(dto.Channel),
- Kind = Enum.Parse(dto.Kind),
- CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
- ExecutionId = NullIfEmpty(dto.ExecutionId) is { } eid ? Guid.Parse(eid) : null,
- ParentExecutionId = NullIfEmpty(dto.ParentExecutionId) is { } pid ? Guid.Parse(pid) : null,
- SourceSiteId = NullIfEmpty(dto.SourceSiteId),
- SourceNode = NullIfEmpty(dto.SourceNode),
- SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
- SourceScript = NullIfEmpty(dto.SourceScript),
- Actor = NullIfEmpty(dto.Actor),
- Target = NullIfEmpty(dto.Target),
- Status = Enum.Parse(dto.Status),
- HttpStatus = dto.HttpStatus,
- DurationMs = dto.DurationMs,
- ErrorMessage = NullIfEmpty(dto.ErrorMessage),
- ErrorDetail = NullIfEmpty(dto.ErrorDetail),
- RequestSummary = NullIfEmpty(dto.RequestSummary),
- ResponseSummary = NullIfEmpty(dto.ResponseSummary),
- PayloadTruncated = dto.PayloadTruncated,
- Extra = NullIfEmpty(dto.Extra),
- ForwardState = null
- };
+ // C3 (Task 2.5): recompose the canonical record from the 24-field wire
+ // DTO. The domain fields are re-serialized into DetailsJson via the
+ // projection helper; IngestedAtUtc is left null (central sets it at
+ // ingest) and ForwardState is dropped (site-storage-only, never on the
+ // wire).
+ return AuditRowProjection.Recompose(new AuditRowProjection.AuditRowValues(
+ EventId: Guid.Parse(dto.EventId),
+ OccurredAtUtc: DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc),
+ IngestedAtUtc: null,
+ Channel: Enum.Parse(dto.Channel),
+ Kind: Enum.Parse(dto.Kind),
+ Status: Enum.Parse(dto.Status),
+ CorrelationId: NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
+ ExecutionId: NullIfEmpty(dto.ExecutionId) is { } eid ? Guid.Parse(eid) : null,
+ ParentExecutionId: NullIfEmpty(dto.ParentExecutionId) is { } pid ? Guid.Parse(pid) : null,
+ SourceSiteId: NullIfEmpty(dto.SourceSiteId),
+ SourceNode: NullIfEmpty(dto.SourceNode),
+ SourceInstanceId: NullIfEmpty(dto.SourceInstanceId),
+ SourceScript: NullIfEmpty(dto.SourceScript),
+ Actor: NullIfEmpty(dto.Actor),
+ Target: NullIfEmpty(dto.Target),
+ HttpStatus: dto.HttpStatus,
+ DurationMs: dto.DurationMs,
+ ErrorMessage: NullIfEmpty(dto.ErrorMessage),
+ ErrorDetail: NullIfEmpty(dto.ErrorDetail),
+ RequestSummary: NullIfEmpty(dto.RequestSummary),
+ ResponseSummary: NullIfEmpty(dto.ResponseSummary),
+ PayloadTruncated: dto.PayloadTruncated,
+ Extra: NullIfEmpty(dto.Extra)));
}
private static string? NullIfEmpty(string? value) =>
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcServer.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcServer.cs
index 5900f6e5..a951ad0d 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcServer.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcServer.cs
@@ -4,7 +4,7 @@ using Akka.Actor;
using Grpc.Core;
using Microsoft.Extensions.Logging;
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.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs
index fb426639..348ef385 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs
@@ -1,16 +1,18 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
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;
///
-/// Maps the record to the central AuditLog table
-/// described in alog.md §4. Column lengths/types and the five named indexes are
-/// fixed by that specification — keep this in sync with the doc.
+/// Maps the persistence shape to the central AuditLog
+/// 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. C3 (Task 2.5) kept
+/// the table unchanged; the canonical record is mapped onto this row at the repository
+/// boundary via AuditRowProjection.
///
-public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration
+public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration
{
// SQL Server's datetime2 provider strips the DateTimeKind flag on the wire
// (a column hydrated from the database always surfaces as
@@ -33,9 +35,9 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : null);
- /// Applies the EF Core type configuration for to the model builder.
+ /// Applies the EF Core type configuration for to the model builder.
/// The entity type builder to configure.
- public void Configure(EntityTypeBuilder builder)
+ public void Configure(EntityTypeBuilder builder)
{
builder.ToTable("AuditLog");
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogRow.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogRow.cs
new file mode 100644
index 00000000..7589d2d3
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogRow.cs
@@ -0,0 +1,113 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+
+namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
+
+///
+/// Transitional EF Core persistence shape for the central dbo.AuditLog table
+/// (Audit Log #23). This is the 24-column row formerly modelled by
+/// ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditEvent; in C3 (Task 2.5)
+/// the canonical ZB.MOM.WW.Audit.AuditEvent 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.
+///
+///
+///
+/// The repository maps canonical ⇄ this row at the persistence boundary via
+/// ZB.MOM.WW.ScadaBridge.Commons.Types.Audit.AuditRowProjection. C5 replaces
+/// this shim + table with the real DetailsJson-backed schema.
+///
+///
+/// All *Utc-suffixed properties are invariantly UTC
+/// (CLAUDE.md: "All timestamps are UTC throughout the system."). The init-setters
+/// force on assignment so a value re-hydrated from a
+/// SQL Server datetime2 column (which strips the Kind flag on the wire)
+/// cannot leak downstream as or be silently
+/// re-interpreted as local time.
+///
+///
+public sealed record AuditLogRow
+{
+ /// Idempotency key; uniquely identifies one audit lifecycle event.
+ public Guid EventId { get; init; }
+
+ /// UTC timestamp when the audited action occurred at its source.
+ public DateTime OccurredAtUtc
+ {
+ get => _occurredAtUtc;
+ init => _occurredAtUtc = DateTime.SpecifyKind(value, DateTimeKind.Utc);
+ }
+ private readonly DateTime _occurredAtUtc;
+
+ /// UTC timestamp when the row was ingested at central; null on the site hot-path.
+ public DateTime? IngestedAtUtc
+ {
+ get => _ingestedAtUtc;
+ init => _ingestedAtUtc = value.HasValue
+ ? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc)
+ : null;
+ }
+ private readonly DateTime? _ingestedAtUtc;
+
+ /// Trust-boundary channel the audited action crossed.
+ public AuditChannel Channel { get; init; }
+
+ /// Specific event kind within the channel.
+ public AuditKind Kind { get; init; }
+
+ /// Correlation id linking related audit rows (e.g. the cached-op lifecycle).
+ public Guid? CorrelationId { get; init; }
+
+ /// Id of the originating script execution / inbound request.
+ public Guid? ExecutionId { get; init; }
+
+ /// ExecutionId of the execution that spawned this run; null for top-level runs.
+ public Guid? ParentExecutionId { get; init; }
+
+ /// Site id where the action originated; null for central-direct events.
+ public string? SourceSiteId { get; init; }
+
+ /// The cluster node on which the event was emitted.
+ public string? SourceNode { get; init; }
+
+ /// Instance id where the action originated, when applicable.
+ public string? SourceInstanceId { get; init; }
+
+ /// Script that initiated the action, when applicable.
+ public string? SourceScript { get; init; }
+
+ /// Authenticated actor for inbound paths (API key name, user, etc.).
+ public string? Actor { get; init; }
+
+ /// Target of the action: external system name, db connection name, list name, or inbound method.
+ public string? Target { get; init; }
+
+ /// Lifecycle status of this row.
+ public AuditStatus Status { get; init; }
+
+ /// HTTP status code where applicable.
+ public int? HttpStatus { get; init; }
+
+ /// Duration of the audited action in milliseconds, when measurable.
+ public int? DurationMs { get; init; }
+
+ /// Human-readable error summary on failure rows.
+ public string? ErrorMessage { get; init; }
+
+ /// Verbose error detail (stack/exception) on failure rows.
+ public string? ErrorDetail { get; init; }
+
+ /// Truncated/redacted request summary; capped per AuditLogOptions.
+ public string? RequestSummary { get; init; }
+
+ /// Truncated/redacted response summary; capped per AuditLogOptions.
+ public string? ResponseSummary { get; init; }
+
+ /// True when Request/Response summaries were truncated to the payload cap.
+ public bool PayloadTruncated { get; init; }
+
+ /// Free-form JSON extension column for channel-specific extras.
+ public string? Extra { get; init; }
+
+ /// Site-local forwarding state; null on central rows.
+ public AuditForwardState? ForwardState { get; init; }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs
index 4eb19ad8..5fa42528 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs
@@ -41,7 +41,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
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("EventId")
.HasColumnType("uniqueidentifier");
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs
index 71059757..7842f243 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs
@@ -2,10 +2,11 @@ using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
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.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
+using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
@@ -45,30 +46,36 @@ public class AuditLogRepository : IAuditLogRepository
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()), so do
// the conversion in C# rather than relying on parameter type inference —
// SqlClient would otherwise bind enums as int by default.
- var channel = evt.Channel.ToString();
- var kind = evt.Kind.ToString();
- var status = evt.Status.ToString();
- var forwardState = evt.ForwardState?.ToString();
+ var channel = r.Channel.ToString();
+ var kind = r.Kind.ToString();
+ var status = r.Status.ToString();
+ string? forwardState = null;
// FormattableString interpolation parameterises every value (no concatenation),
// so this is safe against injection even for the string columns.
try
{
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
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId, ParentExecutionId,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, Status,
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
ResponseSummary, PayloadTruncated, Extra, ForwardState)
VALUES
- ({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, {evt.ExecutionId}, {evt.ParentExecutionId},
- {evt.SourceSiteId}, {evt.SourceNode}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
- {evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
- {evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
+ ({r.EventId}, {r.OccurredAtUtc}, {r.IngestedAtUtc}, {channel}, {kind}, {r.CorrelationId}, {r.ExecutionId}, {r.ParentExecutionId},
+ {r.SourceSiteId}, {r.SourceNode}, {r.SourceInstanceId}, {r.SourceScript}, {r.Actor}, {r.Target}, {status},
+ {r.HttpStatus}, {r.DurationMs}, {r.ErrorMessage}, {r.ErrorDetail}, {r.RequestSummary},
+ {r.ResponseSummary}, {r.PayloadTruncated}, {r.Extra}, {forwardState});",
ct);
}
catch (SqlException ex) when (
@@ -85,7 +92,7 @@ VALUES
ex,
"InsertIfNotExistsAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for EventId {EventId}; treating as no-op.",
ex.Number,
- evt.EventId);
+ r.EventId);
}
}
@@ -103,7 +110,10 @@ VALUES
throw new ArgumentNullException(nameof(paging));
}
- var query = _context.Set().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().AsNoTracking();
// Multi-value dimensions: a null OR empty list means "no constraint"
// (the { Count: > 0 } guard prevents an empty list collapsing to a
@@ -181,13 +191,47 @@ VALUES
|| (e.OccurredAtUtc == afterOccurred && e.EventId.CompareTo(afterEventId) < 0));
}
- return await query
+ var rows = await query
.OrderByDescending(e => e.OccurredAtUtc)
.ThenByDescending(e => e.EventId)
.Take(paging.PageSize)
.ToListAsync(ct);
+
+ return rows.Select(RowToCanonical).ToList();
}
+ ///
+ /// C3 transitional shim: recompose a canonical from a
+ /// materialized read back from dbo.AuditLog.
+ /// ForwardState is dropped (central rows never carry it; it is not a
+ /// canonical / DetailsJson field).
+ ///
+ 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));
+
///
public async Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
{
@@ -674,7 +718,7 @@ VALUES
///
public async Task> GetDistinctSourceNodesAsync(CancellationToken ct = default)
{
- return await _context.Set()
+ return await _context.Set()
.AsNoTracking()
.Where(e => e.SourceNode != null)
.Select(e => e.SourceNode!)
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs
index 58ea4e90..0adb6e55 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs
@@ -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.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
+using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
@@ -124,8 +125,8 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
// Audit
/// Gets the set of audit log entries.
public DbSet AuditLogEntries => Set();
- /// Gets the set of audit logs.
- public DbSet AuditLogs => Set();
+ /// Gets the set of audit log rows (central dbo.AuditLog persistence shape; mapped to/from the canonical record at the repository boundary).
+ public DbSet AuditLogs => Set();
/// Gets the set of site calls.
public DbSet SiteCalls => Set();
diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/Middleware/AuditWriteMiddleware.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/Middleware/AuditWriteMiddleware.cs
index 7766e4df..db342069 100644
--- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/Middleware/AuditWriteMiddleware.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/Middleware/AuditWriteMiddleware.cs
@@ -5,9 +5,10 @@ using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
+using ZB.MOM.WW.Audit;
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.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
@@ -234,37 +235,34 @@ public sealed class AuditWriteMiddleware
userAgent = ctx.Request.Headers.UserAgent.ToString(),
});
- var evt = new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.ApiInbound,
- Kind = kind,
+ var evt = ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiInbound,
+ kind: kind,
+ status: status,
+ occurredAtUtc: DateTime.UtcNow,
+ actor: actor,
+ target: methodName,
// Audit Log #23: the per-request execution id minted ONCE at the
// start of the request (InvokeAsync) and stashed on
// HttpContext.Items. The same id is threaded onto a routed
// RouteToCallRequest.ParentExecutionId by the endpoint handler,
// so an inbound request and the site script it routes to share
// one correlation point. This inbound row stays top-level — its
- // own ParentExecutionId is never set (see below).
- ExecutionId = ResolveInboundExecutionId(ctx),
+ // own ParentExecutionId is never set.
+ executionId: ResolveInboundExecutionId(ctx),
// CorrelationId is purely the per-operation-lifecycle id; an
// inbound request is a one-shot from the audit row's
// perspective with no multi-row operation to correlate.
- CorrelationId = null,
- Actor = actor,
- Target = methodName,
- Status = status,
- HttpStatus = statusCode,
- DurationMs = (int)Math.Min(durationMs, int.MaxValue),
- ErrorMessage = thrown?.Message,
- RequestSummary = requestBody,
- ResponseSummary = responseBody,
- PayloadTruncated = payloadTruncated,
- Extra = extra,
- // Central direct-write — no site-local forwarding state.
- ForwardState = null,
- };
+ correlationId: null,
+ httpStatus: statusCode,
+ durationMs: (int)Math.Min(durationMs, int.MaxValue),
+ errorMessage: thrown?.Message,
+ requestSummary: requestBody,
+ responseSummary: responseBody,
+ payloadTruncated: payloadTruncated,
+ extra: extra);
+ // Central direct-write — no site-local forwarding state (not a
+ // canonical field).
// InboundAPI-018: fire-and-forget the writer so the user-facing
// response stays non-blocking (alog.md §13 — audit emission must
diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs
index 98ff808c..ed1563da 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs
@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
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.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -127,22 +127,26 @@ public static class AuditEndpoints
var paging = ParsePaging(context.Request.Query);
var repo = context.RequestServices.GetRequiredService();
- 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
// when the page came back FULL. A short page means there is no next
// page, so nextCursor is null and the CLI stops paging.
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
{
- afterOccurredAtUtc = last.OccurredAtUtc,
+ // C3: canonical OccurredAtUtc is a DateTimeOffset; the cursor key is UTC.
+ afterOccurredAtUtc = last.OccurredAtUtc.UtcDateTime,
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 };
// EnvelopeJsonOptions keeps an explicit null nextCursor on the wire so
// 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)
{
- await writer.WriteLineAsync(FormatCsvRow(evt));
+ await writer.WriteLineAsync(FormatCsvRow(AuditExportRow.From(evt)));
}
await writer.FlushAsync(ct);
await output.FlushAsync(ct);
@@ -275,7 +279,7 @@ public static class AuditEndpoints
{
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 output.FlushAsync(ct);
@@ -309,7 +313,8 @@ public static class AuditEndpoints
}
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 as an RFC 4180 CSV row matching .
///
/// The audit event to format.
- public static string FormatCsvRow(AuditEvent evt)
+ public static string FormatCsvRow(AuditExportRow evt)
{
var sb = new StringBuilder(256);
AppendField(sb, evt.EventId.ToString(), first: true);
diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditExportRow.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditExportRow.cs
new file mode 100644
index 00000000..3d72fa8e
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditExportRow.cs
@@ -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;
+
+///
+/// Flat, wire-shape view of a canonical for the
+/// management CLI's /api/audit/query + /api/audit/export 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 ) at the endpoint boundary.
+///
+public sealed record AuditExportRow
+{
+ /// Idempotency key.
+ public Guid EventId { get; init; }
+ /// UTC timestamp when the audited action occurred.
+ public DateTime OccurredAtUtc { get; init; }
+ /// UTC ingest timestamp; null until ingest.
+ public DateTime? IngestedAtUtc { get; init; }
+ /// Trust-boundary channel.
+ public AuditChannel Channel { get; init; }
+ /// Specific event kind.
+ public AuditKind Kind { get; init; }
+ /// Per-operation correlation id.
+ public Guid? CorrelationId { get; init; }
+ /// Originating execution id.
+ public Guid? ExecutionId { get; init; }
+ /// Spawning execution id; null for top-level runs.
+ public Guid? ParentExecutionId { get; init; }
+ /// Site id where the action originated.
+ public string? SourceSiteId { get; init; }
+ /// Cluster node that emitted the event.
+ public string? SourceNode { get; init; }
+ /// Instance id where the action originated.
+ public string? SourceInstanceId { get; init; }
+ /// Script that initiated the action.
+ public string? SourceScript { get; init; }
+ /// Authenticated actor.
+ public string? Actor { get; init; }
+ /// Target of the action.
+ public string? Target { get; init; }
+ /// Lifecycle status.
+ public AuditStatus Status { get; init; }
+ /// HTTP status code where applicable.
+ public int? HttpStatus { get; init; }
+ /// Duration of the action in ms.
+ public int? DurationMs { get; init; }
+ /// Human-readable error summary.
+ public string? ErrorMessage { get; init; }
+ /// Verbose error detail.
+ public string? ErrorDetail { get; init; }
+ /// Truncated/redacted request summary.
+ public string? RequestSummary { get; init; }
+ /// Truncated/redacted response summary.
+ public string? ResponseSummary { get; init; }
+ /// True when summaries were truncated.
+ public bool PayloadTruncated { get; init; }
+ /// Free-form JSON extension.
+ public string? Extra { get; init; }
+ /// Site-local forwarding state; always null on the central read path.
+ public AuditForwardState? ForwardState { get; init; }
+
+ /// Decomposes a canonical into this flat export shape.
+ 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,
+ };
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs
index b205f5cf..8689697c 100644
--- a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs
@@ -1,11 +1,12 @@
using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
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.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
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.Notifications;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
@@ -627,8 +628,8 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
{
try
{
- var evt = BuildNotifyDeliverEvent(notification, now, AuditStatus.Attempted, errorMessage)
- with { DurationMs = durationMs };
+ var evt = BuildNotifyDeliverEvent(
+ notification, now, AuditStatus.Attempted, errorMessage, durationMs);
await _auditWriter.WriteAsync(evt);
}
catch (Exception ex)
@@ -658,42 +659,41 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
Notification notification,
DateTimeOffset now,
AuditStatus status,
- string? errorMessage)
+ string? errorMessage,
+ int? durationMs = null)
{
Guid? correlationId = Guid.TryParse(notification.NotificationId, out var parsed)
? parsed
: null;
- return new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = now.UtcDateTime,
- Channel = AuditChannel.Notification,
- Kind = AuditKind.NotifyDeliver,
- CorrelationId = correlationId,
+ return ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.Notification,
+ kind: AuditKind.NotifyDeliver,
+ status: status,
+ occurredAtUtc: now.UtcDateTime,
// Central dispatch — a system identity per the Actor-column spec;
// there is no per-call authenticated user here. The originating
// script is still captured on SourceScript (and on the upstream
// NotifySend row).
- Actor = SystemActor,
- SourceSiteId = notification.SourceSiteId,
- SourceInstanceId = notification.SourceInstanceId,
- SourceScript = notification.SourceScript,
+ actor: SystemActor,
+ target: notification.ListName,
+ correlationId: correlationId,
// ExecutionId (Audit Log #23): the originating script execution's id,
// carried from the site on NotificationSubmit and persisted on the
// Notification row. Echoing it here links the central NotifyDeliver
// rows to the site-emitted NotifySend row for the same run. Null when
// the notification was raised outside a script execution.
- ExecutionId = notification.OriginExecutionId,
+ executionId: notification.OriginExecutionId,
// ParentExecutionId (Audit Log #23): the originating routed run's
// parent ExecutionId, carried from the site on NotificationSubmit and
// persisted on the Notification row. Echoing it here links the central
// NotifyDeliver rows to the routed run's parent. Null for non-routed runs.
- ParentExecutionId = notification.OriginParentExecutionId,
- Target = notification.ListName,
- Status = status,
- ErrorMessage = errorMessage,
- };
+ parentExecutionId: notification.OriginParentExecutionId,
+ sourceSiteId: notification.SourceSiteId,
+ sourceInstanceId: notification.SourceInstanceId,
+ sourceScript: notification.SourceScript,
+ durationMs: durationMs,
+ errorMessage: errorMessage);
}
///
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/AuditingDbCommand.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/AuditingDbCommand.cs
index 595af4c8..424ab2e6 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/AuditingDbCommand.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/AuditingDbCommand.cs
@@ -2,9 +2,10 @@ using System.Data;
using System.Data.Common;
using System.Diagnostics;
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.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
@@ -473,40 +474,36 @@ internal sealed class AuditingDbCommand : DbCommand
? $"{{\"op\":\"write\",\"rowsAffected\":{(rowsAffected ?? 0)}}}"
: $"{{\"op\":\"read\",\"rowsReturned\":{(rowsReturned ?? 0)}}}";
- return new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
- Channel = AuditChannel.DbOutbound,
- Kind = AuditKind.DbWrite,
+ return ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.DbOutbound,
+ kind: AuditKind.DbWrite,
+ status: status,
+ occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
+ // 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
// lifecycle, so CorrelationId is null. ExecutionId carries the
// per-execution id so this row shares an id with the other sync
// trust-boundary rows from the same script run.
- CorrelationId = null,
- ExecutionId = _executionId,
+ correlationId: null,
+ 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 actor is
- // the calling script. Null when no single script owns the call
- // (e.g. a shared script running inline).
- Actor = _sourceScript,
- Target = target,
- Status = status,
- HttpStatus = null,
- DurationMs = durationMs,
- ErrorMessage = thrown?.Message,
- ErrorDetail = thrown?.ToString(),
- RequestSummary = requestSummary,
- ResponseSummary = null,
- PayloadTruncated = false,
- Extra = extra,
- ForwardState = AuditForwardState.Pending,
- };
+ parentExecutionId: _parentExecutionId,
+ sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
+ sourceInstanceId: _instanceName,
+ sourceScript: _sourceScript,
+ httpStatus: null,
+ durationMs: durationMs,
+ errorMessage: thrown?.Message,
+ errorDetail: thrown?.ToString(),
+ requestSummary: requestSummary,
+ responseSummary: null,
+ payloadTruncated: false,
+ extra: extra);
}
///
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs
index d2d6cd3d..94b775f5 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs
@@ -3,7 +3,6 @@ using System.Text.Json;
using System.Text.RegularExpressions;
using Akka.Actor;
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.Services;
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.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
@@ -735,29 +736,25 @@ public class ScriptRuntimeContext
try
{
telemetry = new CachedCallTelemetry(
- Audit: new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.CachedSubmit,
+ Audit: ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.CachedSubmit,
+ status: AuditStatus.Submitted,
+ occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
+ target: target,
// CorrelationId stays the per-operation lifecycle id
// (TrackedOperationId); ExecutionId carries the
// per-execution id shared across this script run.
- CorrelationId = trackedId.Value,
- ExecutionId = _executionId,
+ correlationId: trackedId.Value,
+ 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,
- Target = target,
- Status = AuditStatus.Submitted,
+ parentExecutionId: _parentExecutionId,
+ sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
+ sourceInstanceId: _instanceName,
+ sourceScript: _sourceScript,
// Submit precedes the call — request args only, no response yet.
- RequestSummary = SerializeRequest(parameters),
- ForwardState = AuditForwardState.Pending,
- },
+ requestSummary: SerializeRequest(parameters)),
Operational: new SiteCallOperational(
TrackedOperationId: trackedId,
Channel: "ApiOutbound",
@@ -857,30 +854,26 @@ public class ScriptRuntimeContext
try
{
attempted = new CachedCallTelemetry(
- Audit: new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCallCached,
+ Audit: ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCallCached,
+ status: AuditStatus.Attempted,
+ occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
+ target: target,
// CorrelationId = per-operation lifecycle id;
// ExecutionId = per-execution id for this script run.
- CorrelationId = trackedId.Value,
- ExecutionId = _executionId,
+ correlationId: trackedId.Value,
+ 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,
- Target = target,
- Status = AuditStatus.Attempted,
- HttpStatus = httpStatus,
- ErrorMessage = result.Success ? null : result.ErrorMessage,
- RequestSummary = SerializeRequest(parameters),
- ResponseSummary = result.ResponseJson,
- ForwardState = AuditForwardState.Pending,
- },
+ parentExecutionId: _parentExecutionId,
+ sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
+ sourceInstanceId: _instanceName,
+ sourceScript: _sourceScript,
+ httpStatus: httpStatus,
+ errorMessage: result.Success ? null : result.ErrorMessage,
+ requestSummary: SerializeRequest(parameters),
+ responseSummary: result.ResponseJson),
Operational: new SiteCallOperational(
TrackedOperationId: trackedId,
Channel: "ApiOutbound",
@@ -929,30 +922,26 @@ public class ScriptRuntimeContext
try
{
resolve = new CachedCallTelemetry(
- Audit: new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.CachedResolve,
+ Audit: ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.CachedResolve,
+ status: auditTerminalStatus,
+ occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
+ target: target,
// CorrelationId = per-operation lifecycle id;
// ExecutionId = per-execution id for this script run.
- CorrelationId = trackedId.Value,
- ExecutionId = _executionId,
+ correlationId: trackedId.Value,
+ 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,
- Target = target,
- Status = auditTerminalStatus,
- HttpStatus = httpStatus,
- ErrorMessage = result.Success ? null : result.ErrorMessage,
- RequestSummary = SerializeRequest(parameters),
- ResponseSummary = result.ResponseJson,
- ForwardState = AuditForwardState.Pending,
- },
+ parentExecutionId: _parentExecutionId,
+ sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
+ sourceInstanceId: _instanceName,
+ sourceScript: _sourceScript,
+ httpStatus: httpStatus,
+ errorMessage: result.Success ? null : result.ErrorMessage,
+ requestSummary: SerializeRequest(parameters),
+ responseSummary: result.ResponseJson),
Operational: new SiteCallOperational(
TrackedOperationId: trackedId,
Channel: "ApiOutbound",
@@ -1112,44 +1101,40 @@ public class ScriptRuntimeContext
}
}
- return new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
+ return ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: status,
+ occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
+ // 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
// lifecycle, so CorrelationId is null. ExecutionId carries the
// per-execution id so all the sync ApiCall/DbWrite rows from
// one script run can be correlated together.
- CorrelationId = null,
- ExecutionId = _executionId,
+ correlationId: null,
+ 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 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}",
- Status = status,
- HttpStatus = httpStatus,
- DurationMs = durationMs,
- ErrorMessage = errorMessage,
- ErrorDetail = errorDetail,
+ parentExecutionId: _parentExecutionId,
+ sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
+ sourceInstanceId: _instanceName,
+ sourceScript: _sourceScript,
+ httpStatus: httpStatus,
+ durationMs: durationMs,
+ errorMessage: errorMessage,
+ errorDetail: errorDetail,
// Payload capture: the request arguments and the response body.
- // The audit writer's payload filter applies the configured size
- // cap and header/secret redaction downstream — the emitter just
- // hands over the raw values.
- RequestSummary = SerializeRequest(parameters),
- ResponseSummary = result?.ResponseJson,
- PayloadTruncated = false,
- Extra = null,
- ForwardState = AuditForwardState.Pending,
- };
+ // The audit writer's redactor applies the configured size cap and
+ // header/secret redaction downstream — the emitter just hands
+ // over the raw values.
+ requestSummary: SerializeRequest(parameters),
+ responseSummary: result?.ResponseJson,
+ payloadTruncated: false,
+ extra: null);
}
///
@@ -1383,26 +1368,22 @@ public class ScriptRuntimeContext
try
{
telemetry = new CachedCallTelemetry(
- Audit: new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
- Channel = AuditChannel.DbOutbound,
- Kind = AuditKind.CachedSubmit,
+ Audit: ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.DbOutbound,
+ kind: AuditKind.CachedSubmit,
+ status: AuditStatus.Submitted,
+ occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
+ target: target,
// CorrelationId = per-operation lifecycle id
// (TrackedOperationId); ExecutionId = per-execution id.
- CorrelationId = trackedId.Value,
- ExecutionId = _executionId,
+ correlationId: trackedId.Value,
+ 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,
- Target = target,
- Status = AuditStatus.Submitted,
- ForwardState = AuditForwardState.Pending,
- },
+ parentExecutionId: _parentExecutionId,
+ sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
+ sourceInstanceId: _instanceName,
+ sourceScript: _sourceScript),
Operational: new SiteCallOperational(
TrackedOperationId: trackedId,
Channel: "DbOutbound",
@@ -1830,42 +1811,38 @@ public class ScriptRuntimeContext
body = body,
});
- evt = new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
- Channel = AuditChannel.Notification,
- 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,
+ evt = ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.Notification,
+ kind: AuditKind.NotifySend,
+ status: AuditStatus.Submitted,
+ occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
// 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 = _listName,
- Status = AuditStatus.Submitted,
- HttpStatus = null,
+ actor: _sourceScript,
+ target: _listName,
+ // 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,
+ httpStatus: null,
// Send is fire-and-forget from the script's perspective —
// the dispatcher (NotificationOutboxActor) times each
// delivery attempt and stamps DurationMs on its
// NotifyDeliver(Attempted) rows.
- DurationMs = null,
- ErrorMessage = null,
- ErrorDetail = null,
- RequestSummary = requestSummary,
- ResponseSummary = null,
- PayloadTruncated = false,
- Extra = null,
- ForwardState = AuditForwardState.Pending,
- };
+ durationMs: null,
+ errorMessage: null,
+ errorDetail: null,
+ requestSummary: requestSummary,
+ responseSummary: null,
+ payloadTruncated: false,
+ extra: null);
}
catch (Exception buildEx)
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs
index 5f38de70..4a53645c 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs
@@ -4,6 +4,8 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
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.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
@@ -55,16 +57,14 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
var trackedId = trackedOperationId ?? TrackedOperationId.New();
var now = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
- var audit = new AuditEvent
- {
- EventId = eventId ?? Guid.NewGuid(),
- OccurredAtUtc = now,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.CachedSubmit,
- Status = auditStatus,
- SourceSiteId = siteId,
- CorrelationId = trackedId.Value,
- };
+ var audit = ScadaBridgeAuditEventFactory.Create(
+ eventId: eventId ?? Guid.NewGuid(),
+ occurredAtUtc: now,
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.CachedSubmit,
+ status: auditStatus,
+ sourceSiteId: siteId,
+ correlationId: trackedId.Value);
var siteCall = new SiteCall
{
@@ -137,7 +137,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
// Verify rows landed in both tables.
await using var read = CreateReadContext();
- var auditRow = await read.Set().SingleOrDefaultAsync(e => e.EventId == audit.EventId);
+ var auditRow = await read.Set().SingleOrDefaultAsync(e => e.EventId == audit.EventId);
Assert.NotNull(auditRow);
Assert.NotNull(auditRow!.IngestedAtUtc);
@@ -178,7 +178,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
Assert.Equal(eventId, reply.AcceptedEventIds[0]);
await using var read = CreateReadContext();
- var auditCount = await read.Set().CountAsync(e => e.EventId == eventId);
+ var auditCount = await read.Set().CountAsync(e => e.EventId == eventId);
Assert.Equal(1, auditCount);
var siteCallCount = await read.Set()
@@ -221,7 +221,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
// Both audit rows exist.
await using var read = CreateReadContext();
- var auditRows = await read.Set()
+ var auditRows = await read.Set()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(2, auditRows.Count);
@@ -256,7 +256,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
Assert.Empty(reply.AcceptedEventIds);
await using var read = CreateReadContext();
- var auditRow = await read.Set().SingleOrDefaultAsync(e => e.EventId == audit.EventId);
+ var auditRow = await read.Set().SingleOrDefaultAsync(e => e.EventId == audit.EventId);
Assert.Null(auditRow);
var siteCallRow = await read.Set()
@@ -287,7 +287,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
.SetEquals(reply.AcceptedEventIds.ToHashSet()));
await using var read = CreateReadContext();
- var auditCount = await read.Set().CountAsync(e => e.SourceSiteId == siteId);
+ var auditCount = await read.Set().CountAsync(e => e.SourceSiteId == siteId);
Assert.Equal(5, auditCount);
var siteCallCount = await read.Set().CountAsync(s => s.SourceSite == siteId);
@@ -329,7 +329,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
Assert.DoesNotContain(audit2.EventId, reply.AcceptedEventIds);
await using var read = CreateReadContext();
- var auditRows = await read.Set().Where(e => e.SourceSiteId == siteId).ToListAsync();
+ var auditRows = await read.Set().Where(e => e.SourceSiteId == siteId).ToListAsync();
Assert.Equal(2, auditRows.Count);
Assert.DoesNotContain(auditRows, r => r.EventId == audit2.EventId);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs
index b546fb8e..7c90df45 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs
@@ -3,7 +3,8 @@ using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
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.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -41,15 +42,13 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture
"test-bundle-d2-" + Guid.NewGuid().ToString("N").Substring(0, 8);
- private static AuditEvent NewEvent(string siteId, Guid? id = null) => new()
- {
- EventId = id ?? Guid.NewGuid(),
- OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- SourceSiteId = siteId,
- };
+ private static AuditEvent NewEvent(string siteId, Guid? id = null) => ScadaBridgeAuditEventFactory.Create(
+ eventId: id ?? Guid.NewGuid(),
+ occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ sourceSiteId: siteId);
private IActorRef CreateActor(IAuditLogRepository repository) =>
Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
@@ -76,7 +75,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture()
+ var rows = await readContext.Set()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(5, rows.Count);
@@ -115,7 +114,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture()
+ var count = await readContext.Set()
.Where(e => e.SourceSiteId == siteId)
.CountAsync();
Assert.Equal(3, count);
@@ -141,7 +140,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture()
+ var rows = await readContext.Set()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
@@ -178,7 +177,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture()
+ var rows = await readContext.Set()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(4, rows.Count);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs
index 2b9a0b35..228d7446 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs
@@ -6,7 +6,8 @@ using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
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.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -272,24 +273,20 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture()
+ var rows = await verifyContext.Set()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs
index 5085217f..1f0d62cb 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs
@@ -3,7 +3,7 @@ using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
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.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -22,14 +22,12 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
///
public class CentralAuditWriteFailuresTests : TestKit
{
- private static AuditEvent NewEvent() => new()
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- };
+ private static AuditEvent NewEvent() => ScadaBridgeAuditEventFactory.Create(
+ eventId: Guid.NewGuid(),
+ occurredAtUtc: DateTime.UtcNow,
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered);
///
/// Repository stub that always throws on insert — exercises the failure
@@ -84,7 +82,7 @@ public class CentralAuditWriteFailuresTests : TestKit
var writer = new CentralAuditWriter(
sp,
NullLogger.Instance,
- filter: null,
+ redactor: null,
failureCounter: counter);
// WriteAsync swallows the exception and increments the counter.
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriterTests.cs
index 84dc6d51..dbff7744 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriterTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriterTests.cs
@@ -4,7 +4,8 @@ using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
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.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -22,16 +23,16 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
///
public class CentralAuditWriterTests
{
- private static AuditEvent NewEvent(Guid? eventId = null) => new()
- {
- EventId = eventId ?? Guid.NewGuid(),
- OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
- Channel = AuditChannel.Notification,
- Kind = AuditKind.NotifyDeliver,
- Status = AuditStatus.Attempted,
- CorrelationId = Guid.NewGuid(),
- Target = "ops-team",
- };
+ // C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory.
+ private static AuditEvent NewEvent(Guid? eventId = null) =>
+ ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.Notification,
+ kind: AuditKind.NotifyDeliver,
+ status: AuditStatus.Attempted,
+ eventId: eventId ?? Guid.NewGuid(),
+ occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
+ target: "ops-team",
+ correlationId: Guid.NewGuid());
private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriter()
{
@@ -65,10 +66,12 @@ public class CentralAuditWriterTests
var after = DateTime.UtcNow;
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(e =>
- e.IngestedAtUtc != null &&
- e.IngestedAtUtc >= before &&
- e.IngestedAtUtc <= after),
+ e.AsRow().IngestedAtUtc != null &&
+ e.AsRow().IngestedAtUtc >= before &&
+ e.AsRow().IngestedAtUtc <= after),
Arg.Any());
}
@@ -138,7 +141,7 @@ public class CentralAuditWriterTests
var writer = new CentralAuditWriter(
provider,
NullLogger.Instance,
- filter: null,
+ redactor: null,
failureCounter: null,
nodeIdentity: nodeIdentity);
return (writer, repo);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs
index 31655a24..8128ad95 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs
@@ -5,7 +5,8 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
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.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -38,15 +39,13 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture new()
- {
- EventId = id ?? Guid.NewGuid(),
- OccurredAtUtc = occurredAt ?? new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- SourceSiteId = siteId,
- };
+ Guid? id = null) => ScadaBridgeAuditEventFactory.Create(
+ eventId: id ?? Guid.NewGuid(),
+ occurredAtUtc: occurredAt ?? new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ sourceSiteId: siteId);
private static SiteAuditReconciliationOptions FastTickOptions(
int batchSize = 256,
@@ -312,7 +311,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture()
+ var rows = await read.Set()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(2, rows.Count);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs
index 7bc29f15..3892f876 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs
@@ -4,8 +4,8 @@ using Microsoft.Extensions.DependencyInjection;
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.AuditLog.Redaction;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;
@@ -95,25 +95,23 @@ public class AuditLogOptionsBindingTests
// PayloadTruncated flips to true.
var initial = new AuditLogOptions { DefaultCapBytes = 4096 };
var monitor = new TestOptionsMonitor(initial);
- var filter = new DefaultAuditPayloadFilter(
+ var filter = new ScadaBridgeAuditRedactor(
monitor,
- NullLogger.Instance);
+ NullLogger.Instance);
var body = new string('x', 5 * 1024);
- var evt = new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- RequestSummary = body,
- };
+ var evt = ScadaBridgeAuditEventFactory.Create(
+ eventId: Guid.NewGuid(),
+ occurredAtUtc: DateTime.UtcNow,
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ requestSummary: body);
var resultBefore = filter.Apply(evt);
- Assert.True(resultBefore.PayloadTruncated, "5KB body at 4096 cap must be truncated");
- Assert.NotNull(resultBefore.RequestSummary);
- Assert.True(Encoding.UTF8.GetByteCount(resultBefore.RequestSummary!) <= 4096);
+ Assert.True(resultBefore.AsRow().PayloadTruncated, "5KB body at 4096 cap must be truncated");
+ Assert.NotNull(resultBefore.AsRow().RequestSummary);
+ Assert.True(Encoding.UTF8.GetByteCount(resultBefore.AsRow().RequestSummary!) <= 4096);
// 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
@@ -121,8 +119,8 @@ public class AuditLogOptionsBindingTests
monitor.Set(new AuditLogOptions { DefaultCapBytes = 16384 });
var resultAfter = filter.Apply(evt);
- Assert.False(resultAfter.PayloadTruncated, "5KB body at 16384 cap must NOT be truncated");
- Assert.Equal(body, resultAfter.RequestSummary);
+ Assert.False(resultAfter.AsRow().PayloadTruncated, "5KB body at 16384 cap must NOT be truncated");
+ Assert.Equal(body, resultAfter.AsRow().RequestSummary);
}
[Fact]
@@ -133,23 +131,21 @@ public class AuditLogOptionsBindingTests
// process restart. Pre-reload: no redactor, hunter2 survives. After
// reload: hunter2 redacted.
var monitor = new TestOptionsMonitor(new AuditLogOptions());
- var filter = new DefaultAuditPayloadFilter(
+ var filter = new ScadaBridgeAuditRedactor(
monitor,
- NullLogger.Instance);
+ NullLogger.Instance);
const string body = "{\"user\":\"alice\",\"password\":\"hunter2\"}";
- var evt = new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- RequestSummary = body,
- };
+ var evt = ScadaBridgeAuditEventFactory.Create(
+ eventId: Guid.NewGuid(),
+ occurredAtUtc: DateTime.UtcNow,
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ requestSummary: body);
var before = filter.Apply(evt);
- Assert.Contains("hunter2", before.RequestSummary!);
+ Assert.Contains("hunter2", before.AsRow().RequestSummary!);
monitor.Set(new AuditLogOptions
{
@@ -157,8 +153,8 @@ public class AuditLogOptionsBindingTests
});
var after = filter.Apply(evt);
- Assert.DoesNotContain("hunter2", after.RequestSummary!);
- Assert.Contains("", after.RequestSummary!);
+ Assert.DoesNotContain("hunter2", after.AsRow().RequestSummary!);
+ Assert.Contains("", after.AsRow().RequestSummary!);
}
///
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/AuditWriteFailureSafetyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/AuditWriteFailureSafetyTests.cs
index 9e2846e9..34c94dd9 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/AuditWriteFailureSafetyTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/AuditWriteFailureSafetyTests.cs
@@ -11,7 +11,9 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
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.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs
index 28d531a2..3a37cbc9 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs
@@ -1,7 +1,10 @@
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
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.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
@@ -53,20 +56,17 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture
new(
- Audit: new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = nowUtc,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.CachedSubmit,
- CorrelationId = id.Value,
- SourceSiteId = siteId,
- SourceInstanceId = "Plant.Pump42",
- SourceScript = "ScriptActor:doStuff",
- Target = target,
- Status = AuditStatus.Submitted,
- ForwardState = AuditForwardState.Pending,
- },
+ Audit: ScadaBridgeAuditEventFactory.Create(
+ eventId: Guid.NewGuid(),
+ occurredAtUtc: nowUtc,
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.CachedSubmit,
+ correlationId: id.Value,
+ sourceSiteId: siteId,
+ sourceInstanceId: "Plant.Pump42",
+ sourceScript: "ScriptActor:doStuff",
+ target: target,
+ status: AuditStatus.Submitted),
Operational: new SiteCallOperational(
TrackedOperationId: id,
Channel: "ApiOutbound",
@@ -149,7 +149,7 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture()
+ var auditRows = await read.Set()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.InRange(auditRows.Count, 4, 5);
@@ -215,7 +215,7 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture()
+ var resolve = await read.Set()
.Where(e => e.SourceSiteId == siteId && e.Kind == AuditKind.CachedResolve)
.SingleAsync();
Assert.Equal(AuditStatus.Parked, resolve.Status);
@@ -255,7 +255,7 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture()
+ var auditRows = await read.Set()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(3, auditRows.Count);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CachedWriteCombinedTelemetryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CachedWriteCombinedTelemetryTests.cs
index 50dc3dc2..43e0a87c 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CachedWriteCombinedTelemetryTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CachedWriteCombinedTelemetryTests.cs
@@ -1,7 +1,10 @@
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
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.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
@@ -42,20 +45,17 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture
new(
- Audit: new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = nowUtc,
- Channel = AuditChannel.DbOutbound,
- Kind = AuditKind.CachedSubmit,
- CorrelationId = id.Value,
- SourceSiteId = siteId,
- SourceInstanceId = "Plant.Pump42",
- SourceScript = "ScriptActor:doStuff",
- Target = target,
- Status = AuditStatus.Submitted,
- ForwardState = AuditForwardState.Pending,
- },
+ Audit: ScadaBridgeAuditEventFactory.Create(
+ eventId: Guid.NewGuid(),
+ occurredAtUtc: nowUtc,
+ channel: AuditChannel.DbOutbound,
+ kind: AuditKind.CachedSubmit,
+ correlationId: id.Value,
+ sourceSiteId: siteId,
+ sourceInstanceId: "Plant.Pump42",
+ sourceScript: "ScriptActor:doStuff",
+ target: target,
+ status: AuditStatus.Submitted),
Operational: new SiteCallOperational(
TrackedOperationId: id,
Channel: "DbOutbound",
@@ -122,7 +122,7 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture()
+ var auditRows = await read.Set()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(3, auditRows.Count);
@@ -182,7 +182,7 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture()
+ var resolve = await read.Set()
.Where(e => e.SourceSiteId == siteId && e.Kind == AuditKind.CachedResolve)
.SingleAsync();
Assert.Equal(AuditStatus.Parked, resolve.Status);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CombinedTelemetryIdempotencyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CombinedTelemetryIdempotencyTests.cs
index e85f335a..acf143ca 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CombinedTelemetryIdempotencyTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/CombinedTelemetryIdempotencyTests.cs
@@ -1,7 +1,10 @@
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
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.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -54,20 +57,17 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixture()
+ var auditCount = await read.Set()
.CountAsync(e => e.EventId == eventId);
Assert.Equal(1, auditCount);
@@ -183,7 +183,7 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixture()
+ var auditRows = await read.Set()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(2, auditRows.Count);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/DatabaseSyncEmissionEndToEndTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/DatabaseSyncEmissionEndToEndTests.cs
index 998e4e20..c6490b4d 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/DatabaseSyncEmissionEndToEndTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/DatabaseSyncEmissionEndToEndTests.cs
@@ -220,17 +220,17 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture
{
- Assert.NotNull(r.ExecutionId);
- Assert.Equal(executionId, r.ExecutionId);
- Assert.Equal(siteId, r.SourceSiteId);
+ Assert.NotNull(r.AsRow().ExecutionId);
+ Assert.Equal(executionId, r.AsRow().ExecutionId);
+ Assert.Equal(siteId, r.AsRow().SourceSiteId);
// 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
// outbound API call and one outbound DB write — proving the shared
// 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.Channel == AuditChannel.DbOutbound && r.Kind == AuditKind.DbWrite);
+ Assert.Single(rows, r => r.AsRow().Channel == AuditChannel.ApiOutbound && r.AsRow().Kind == AuditKind.ApiCall);
+ Assert.Single(rows, r => r.AsRow().Channel == AuditChannel.DbOutbound && r.AsRow().Kind == AuditKind.DbWrite);
}, TimeSpan.FromSeconds(15));
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/InboundApiAuditTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/InboundApiAuditTests.cs
index 3a151714..c5c84506 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/InboundApiAuditTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/InboundApiAuditTests.cs
@@ -8,7 +8,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
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.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -223,15 +223,13 @@ public class InboundApiAuditTests : IClassFixture
Assert.Equal(System.Net.HttpStatusCode.OK, resp.StatusCode);
var evt = await AwaitOneAsync(methodName);
- Assert.Equal(AuditChannel.ApiInbound, evt.Channel);
- Assert.Equal(AuditKind.InboundRequest, evt.Kind);
- Assert.Equal(AuditStatus.Delivered, evt.Status);
- Assert.Equal(200, evt.HttpStatus);
+ Assert.Equal(AuditChannel.ApiInbound, evt.AsRow().Channel);
+ Assert.Equal(AuditKind.InboundRequest, evt.AsRow().Kind);
+ Assert.Equal(AuditStatus.Delivered, evt.AsRow().Status);
+ Assert.Equal(200, evt.AsRow().HttpStatus);
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.
- Assert.NotNull(evt.IngestedAtUtc);
+ Assert.NotNull(evt.AsRow().IngestedAtUtc);
}
[SkippableFact]
@@ -257,13 +255,15 @@ public class InboundApiAuditTests : IClassFixture
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, resp.StatusCode);
var evt = await AwaitOneAsync(methodName);
- Assert.Equal(AuditChannel.ApiInbound, evt.Channel);
- Assert.Equal(AuditKind.InboundAuthFailure, evt.Kind);
- Assert.Equal(AuditStatus.Failed, evt.Status);
- Assert.Equal(401, evt.HttpStatus);
+ Assert.Equal(AuditChannel.ApiInbound, evt.AsRow().Channel);
+ Assert.Equal(AuditKind.InboundAuthFailure, evt.AsRow().Kind);
+ Assert.Equal(AuditStatus.Failed, evt.AsRow().Status);
+ Assert.Equal(401, evt.AsRow().HttpStatus);
// Never echo back an unauthenticated principal — middleware suppresses
- // the framework user resolution on 401/403 paths.
- Assert.Null(evt.Actor);
+ // the framework user resolution on 401/403 paths. C3 (Task 2.5): the
+ // 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]
@@ -290,10 +290,10 @@ public class InboundApiAuditTests : IClassFixture
Assert.Equal(System.Net.HttpStatusCode.InternalServerError, resp.StatusCode);
var evt = await AwaitOneAsync(methodName);
- Assert.Equal(AuditChannel.ApiInbound, evt.Channel);
- Assert.Equal(AuditKind.InboundRequest, evt.Kind);
- Assert.Equal(AuditStatus.Failed, evt.Status);
- Assert.Equal(500, evt.HttpStatus);
+ Assert.Equal(AuditChannel.ApiInbound, evt.AsRow().Channel);
+ Assert.Equal(AuditKind.InboundRequest, evt.AsRow().Kind);
+ Assert.Equal(AuditStatus.Failed, evt.AsRow().Status);
+ Assert.Equal(500, evt.AsRow().HttpStatus);
Assert.Equal("integration-svc", evt.Actor);
}
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/Infrastructure/DirectActorSiteStreamAuditClient.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/Infrastructure/DirectActorSiteStreamAuditClient.cs
index e2c9e2e8..85bab4e6 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/Infrastructure/DirectActorSiteStreamAuditClient.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/Infrastructure/DirectActorSiteStreamAuditClient.cs
@@ -1,6 +1,8 @@
using Akka.Actor;
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.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/NotifyDispatcherAuditTrailTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/NotifyDispatcherAuditTrailTests.cs
index 4177efc9..9135011d 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/NotifyDispatcherAuditTrailTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/NotifyDispatcherAuditTrailTests.cs
@@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
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.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
@@ -185,21 +185,18 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture r.Kind == AuditKind.NotifyDeliver
- && r.Status == AuditStatus.Attempted);
- Assert.Single(rows, r => r.Kind == AuditKind.NotifySend);
+ Assert.Single(rows, r => r.AsRow().Kind == AuditKind.NotifyDeliver
+ && r.AsRow().Status == AuditStatus.Attempted);
+ Assert.Single(rows, r => r.AsRow().Kind == AuditKind.NotifySend);
}, TimeSpan.FromSeconds(15));
// Second tick: success → second Attempted + one Delivered terminal.
@@ -265,10 +262,10 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture r.Kind == AuditKind.NotifyDeliver)
+ .Where(r => r.AsRow().Kind == AuditKind.NotifyDeliver)
.ToList();
- Assert.Equal(2, notifyDeliverRows.Count(r => r.Status == AuditStatus.Attempted));
- var terminal = Assert.Single(notifyDeliverRows, r => r.Status == AuditStatus.Delivered);
+ Assert.Equal(2, notifyDeliverRows.Count(r => r.AsRow().Status == AuditStatus.Attempted));
+ var terminal = Assert.Single(notifyDeliverRows, r => r.AsRow().Status == AuditStatus.Delivered);
// All NotifyDeliver rows correlate to the original notification id.
Assert.All(notifyDeliverRows, r => Assert.Equal(notificationId, r.CorrelationId));
Assert.Equal("ops-team", terminal.Target);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/OutageReconciliationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/OutageReconciliationTests.cs
index 5037e382..c59c311c 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/OutageReconciliationTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/OutageReconciliationTests.cs
@@ -7,7 +7,9 @@ using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
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.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.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
@@ -129,16 +131,14 @@ public class OutageReconciliationTests : TestKit, IClassFixture()
.UseSqlServer(_fixture.ConnectionString).Options);
- private static AuditEvent NewEvent(string siteId, DateTime occurredAt) => new()
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = occurredAt,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- SourceSiteId = siteId,
- Target = "external-system-a/method",
- };
+ private static AuditEvent NewEvent(string siteId, DateTime occurredAt) => ScadaBridgeAuditEventFactory.Create(
+ eventId: Guid.NewGuid(),
+ occurredAtUtc: occurredAt,
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ sourceSiteId: siteId,
+ target: "external-system-a/method");
private SqliteAuditWriter CreateInMemorySqliteWriter() =>
new SqliteAuditWriter(
@@ -243,7 +243,7 @@ public class OutageReconciliationTests : TestKit, IClassFixture
{
await using var ctx = CreateContext();
- var count = await ctx.Set()
+ var count = await ctx.Set()
.Where(e => e.SourceSiteId == siteId)
.CountAsync();
Assert.Equal(totalEvents, count);
@@ -265,7 +265,7 @@ public class OutageReconciliationTests : TestKit, IClassFixture()
+ var centralIds = await verify.Set()
.Where(e => e.SourceSiteId == siteId)
.Select(e => e.EventId)
.ToListAsync();
@@ -317,7 +317,7 @@ public class OutageReconciliationTests : TestKit, IClassFixture
{
await using var ctx = CreateContext();
- var count = await ctx.Set()
+ var count = await ctx.Set()
.Where(e => e.SourceSiteId == siteId)
.CountAsync();
Assert.Equal(totalEvents, count);
@@ -339,7 +339,7 @@ public class OutageReconciliationTests : TestKit, IClassFixture()
+ var rows = await verify.Set()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(totalEvents, rows.Count);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs
index 8a805f1c..99459613 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs
@@ -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.Tests.Integration.Infrastructure;
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.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
@@ -315,23 +316,23 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture $"{r.Channel}/{r.Kind}/{r.Status}")));
- Assert.Single(siteRows, r => r.Channel == AuditChannel.ApiOutbound && r.Kind == AuditKind.ApiCall);
- Assert.Single(siteRows, r => r.Kind == AuditKind.CachedSubmit);
- Assert.Single(siteRows, r => r.Kind == AuditKind.CachedResolve);
- Assert.Single(siteRows, r => r.Kind == AuditKind.NotifySend);
- Assert.Equal(2, siteRows.Count(r => r.Kind == AuditKind.NotifyDeliver));
+ + string.Join(", ", siteRows.Select(r => $"{r.AsRow().Channel}/{r.AsRow().Kind}/{r.AsRow().Status}")));
+ Assert.Single(siteRows, r => r.AsRow().Channel == AuditChannel.ApiOutbound && r.AsRow().Kind == AuditKind.ApiCall);
+ Assert.Single(siteRows, r => r.AsRow().Kind == AuditKind.CachedSubmit);
+ Assert.Single(siteRows, r => r.AsRow().Kind == AuditKind.CachedResolve);
+ Assert.Single(siteRows, r => r.AsRow().Kind == AuditKind.NotifySend);
+ Assert.Equal(2, siteRows.Count(r => r.AsRow().Kind == AuditKind.NotifyDeliver));
// CORE PROMISE: every routed-run row carries the SAME non-null
// 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.NotNull(parentIds[0]);
var inboundExecutionId = parentIds[0]!.Value;
// The routed run has its OWN distinct ExecutionId — not the parent's.
var routedExecutionIds = siteRows
- .Select(r => r.ExecutionId)
+ .Select(r => r.AsRow().ExecutionId)
.Distinct()
.ToList();
Assert.Single(routedExecutionIds);
@@ -345,9 +346,9 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture r.Channel == AuditChannel.ApiInbound && r.Kind == AuditKind.InboundRequest);
- Assert.Equal(AuditStatus.Delivered, inboundRow.Status);
- Assert.Null(inboundRow.ParentExecutionId);
+ r => r.AsRow().Channel == AuditChannel.ApiInbound && r.AsRow().Kind == AuditKind.InboundRequest);
+ Assert.Equal(AuditStatus.Delivered, inboundRow.AsRow().Status);
+ Assert.Null(inboundRow.AsRow().ParentExecutionId);
// The parentExecutionId filter pulls the routed run's complete
// trust-boundary footprint (all 7 routed rows, none of the inbound).
@@ -355,7 +356,7 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture Assert.Equal(routedExecutionId, r.ExecutionId));
+ Assert.All(byParent, r => Assert.Equal(routedExecutionId, r.AsRow().ExecutionId));
// GetExecutionTreeAsync returns BOTH executions in one chain —
// inbound (root) and routed (child), regardless of entry point.
@@ -502,7 +503,7 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture r.Kind).ToHashSet();
+ .Select(r => r.AsRow().Kind).ToHashSet();
var missing = expectedKinds.Where(k => !kinds.Contains(k)).ToList();
Assert.True(
missing.Count == 0,
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionPurgeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionPurgeTests.cs
index eed7f876..4cb7abc9 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionPurgeTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionPurgeTests.cs
@@ -7,7 +7,9 @@ using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
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.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
@@ -201,7 +203,7 @@ WHERE name = 'UX_AuditLog_EventId'
await Task.Delay(TimeSpan.FromMilliseconds(500));
await using var verify = CreateContext();
- var rows = await verify.Set()
+ var rows = await verify.Set()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
@@ -325,16 +327,14 @@ WHERE name = 'UX_AuditLog_EventId'
var freshEventId = Guid.NewGuid();
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 freshEvt = new AuditEvent
- {
- EventId = freshEventId,
- OccurredAtUtc = freshOccurred,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- SourceSiteId = freshSite,
- Target = "system-x/method",
- };
+ var freshEvt = ScadaBridgeAuditEventFactory.Create(
+ eventId: freshEventId,
+ occurredAtUtc: freshOccurred,
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ sourceSiteId: freshSite,
+ target: "system-x/method");
await using (var ctx = CreateContext())
{
@@ -345,7 +345,7 @@ WHERE name = 'UX_AuditLog_EventId'
}
await using var verify = CreateContext();
- var rows = await verify.Set()
+ var rows = await verify.Set()
.Where(e => e.SourceSiteId == freshSite)
.ToListAsync();
Assert.Single(rows);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs
index 2d113f02..aa1278ce 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs
@@ -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.Tests.Integration.Infrastructure;
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.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -67,16 +67,14 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture new()
- {
- EventId = id ?? Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- SourceSiteId = siteId,
- Target = "external-system-a/method",
- };
+ private static AuditEvent NewEvent(string siteId, Guid? id = null) => ScadaBridgeAuditEventFactory.Create(
+ eventId: id ?? Guid.NewGuid(),
+ occurredAtUtc: DateTime.UtcNow,
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ sourceSiteId: siteId,
+ target: "external-system-a/method");
private static IOptions InMemorySqliteOptions() =>
Options.Create(new SqliteAuditWriterOptions
@@ -167,7 +165,7 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture
-/// Bundle B (M5-T4) tests for body regex redaction in
-/// . The body-redactor stage runs
-/// regex replace against RequestSummary / ResponseSummary / ErrorDetail /
-/// Extra, replacing every match with <redacted>. Regexes come
-/// from plus the per-target
-/// . Each
-/// regex is compiled with a 50 ms timeout so catastrophic-backtracking
-/// patterns trip a ;
-/// when that happens the offending field is over-redacted with
-/// <redacted: redactor error> and the
-/// is incremented. The stage runs
-/// BEFORE truncation.
-///
-public class BodyRegexRedactionTests
-{
- private static IOptionsMonitor Monitor(AuditLogOptions? opts = null) =>
- new StaticMonitor(opts ?? new AuditLogOptions());
-
- private static DefaultAuditPayloadFilter Filter(
- AuditLogOptions? opts = null,
- IAuditRedactionFailureCounter? counter = null) =>
- new(Monitor(opts), NullLogger.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 { "\"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("", result.RequestSummary);
- Assert.DoesNotContain("hunter2", result.RequestSummary);
- Assert.Contains("alice", result.RequestSummary);
- }
-
- [Fact]
- public void PerTargetRegex_OnlyAppliedToMatchingTarget()
- {
- var opts = new AuditLogOptions
- {
- PerTargetOverrides = new Dictionary
- {
- ["esg.A"] = new PerTargetRedactionOverride
- {
- AdditionalBodyRedactors = new List { "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("", 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 { "^(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("", 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 { "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("", 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 { 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("", result.RequestSummary);
- Assert.True(counter.Count >= 1);
- }
-
- /// Test double that counts increments.
- private sealed class CountingRedactionFailureCounter : IAuditRedactionFailureCounter
- {
- private int _count;
- public int Count => _count;
- public void Increment() => System.Threading.Interlocked.Increment(ref _count);
- }
-
- /// IOptionsMonitor test double — returns the same snapshot on every read.
- private sealed class StaticMonitor : IOptionsMonitor
- {
- private readonly AuditLogOptions _value;
- public StaticMonitor(AuditLogOptions value) => _value = value;
- public AuditLogOptions CurrentValue => _value;
- public AuditLogOptions Get(string? name) => _value;
- public IDisposable? OnChange(Action listener) => null;
- }
-}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/FilterIntegrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/FilterIntegrationTests.cs
deleted file mode 100644
index 853a1665..00000000
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/FilterIntegrationTests.cs
+++ /dev/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;
-
-///
-/// Bundle C (M5-T6) integration tests verifying that the
-/// wires correctly into each of the three
-/// writer entry points — on the site hot
-/// path, on the central direct-write path,
-/// and on the site→central telemetry ingest
-/// path (both the per-row IngestAuditEventsCommand handler and the
-/// combined IngestCachedTelemetryCommand dual-write handler).
-///
-///
-/// 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 PayloadTruncated=true, 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
-/// through every test so the
-/// integration is real end-to-end, not a fake-filter assertion.
-///
-public class FilterIntegrationTests
-{
- ///
- /// 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.
- ///
- private static IAuditPayloadFilter NewDefaultFilter()
- {
- var monitor = Microsoft.Extensions.Options.Options.Create(new AuditLogOptions());
- return new DefaultAuditPayloadFilter(
- new StaticMonitor(monitor.Value),
- NullLogger.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.Instance,
- new FakeNodeIdentityProvider(),
- connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
- await using var _disposeSqlite = sqliteWriter;
-
- var fallback = new FallbackAuditWriter(
- sqliteWriter,
- new RingBufferFallback(),
- new NoOpAuditWriteFailureCounter(),
- NullLogger.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();
- var services = new ServiceCollection();
- services.AddScoped(_ => repo);
- services.AddSingleton(NewDefaultFilter());
- var provider = services.BuildServiceProvider();
-
- var writer = new CentralAuditWriter(
- provider, NullLogger.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(e =>
- e.EventId == evt.EventId
- && e.RequestSummary != null
- && Encoding.UTF8.GetByteCount(e.RequestSummary) == 8192
- && e.PayloadTruncated == true),
- Arg.Any());
- }
-
- // -- C1.3 + C1.4: AuditLogIngestActor applies the filter on both paths ---
-
- public class IngestActorTests : TestKit, IClassFixture
- {
- private readonly MsSqlMigrationFixture _fixture;
-
- public IngestActorTests(MsSqlMigrationFixture fixture)
- {
- _fixture = fixture;
- }
-
- private ScadaBridgeDbContext CreateReadContext()
- {
- var options = new DbContextOptionsBuilder()
- .UseSqlServer(_fixture.ConnectionString)
- .Options;
- return new ScadaBridgeDbContext(options);
- }
-
- private static string NewSiteId() =>
- "test-bundle-c1-filter-" + Guid.NewGuid().ToString("N").Substring(0, 8);
-
- ///
- /// Build the IServiceProvider in the production-flavoured shape —
- /// scoped repositories + a singleton
- /// resolved per-message from the actor's scope. Matches the
- /// AddAuditLog registrations Bundle B established.
- ///
- private IServiceProvider BuildServiceProvider()
- {
- var services = new ServiceCollection();
- services.AddDbContext(opts =>
- opts.UseSqlServer(_fixture.ConnectionString)
- .ConfigureWarnings(w => w.Ignore(
- Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
- services.AddScoped(sp =>
- new AuditLogRepository(sp.GetRequiredService()));
- services.AddScoped(sp =>
- new SiteCallAuditRepository(sp.GetRequiredService()));
- 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.Instance)));
-
- actor.Tell(new IngestAuditEventsCommand(new[] { evt }), TestActor);
- ExpectMsg(TimeSpan.FromSeconds(15));
-
- // Verify the persisted row was filtered before INSERT.
- await using var read = CreateReadContext();
- var row = await read.Set()
- .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.Instance)));
-
- actor.Tell(
- new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(audit, siteCall) }),
- TestActor);
- ExpectMsg(TimeSpan.FromSeconds(15));
-
- await using var read = CreateReadContext();
- var auditRow = await read.Set()
- .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);
- }
- }
-
- ///
- /// IOptionsMonitor test double — returns the same snapshot on every read,
- /// no change-token plumbing required for these tests. Mirrors the helper
- /// used in TruncationTests.
- ///
- private sealed class StaticMonitor : IOptionsMonitor
- {
- private readonly AuditLogOptions _value;
-
- public StaticMonitor(AuditLogOptions value) => _value = value;
-
- public AuditLogOptions CurrentValue => _value;
-
- public AuditLogOptions Get(string? name) => _value;
-
- public IDisposable? OnChange(Action listener) => null;
- }
-}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/HeaderRedactionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/HeaderRedactionTests.cs
deleted file mode 100644
index 6bf83335..00000000
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/HeaderRedactionTests.cs
+++ /dev/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;
-
-///
-/// Bundle B (M5-T3) tests for HTTP header
-/// redaction. Redaction parses /
-/// as JSON of shape
-/// {"headers": {"name": "value", ...}, "body": "..."}, replaces values
-/// whose header NAME (case-insensitive) is in
-/// with "<redacted>",
-/// 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.
-///
-public class HeaderRedactionTests
-{
- private static IOptionsMonitor Monitor(AuditLogOptions? opts = null) =>
- new StaticMonitor(opts ?? new AuditLogOptions());
-
- private static DefaultAuditPayloadFilter Filter(AuditLogOptions? opts = null) =>
- new(Monitor(opts), NullLogger.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 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 ParseSummary(string? summary)
- {
- Assert.NotNull(summary);
- using var doc = JsonDocument.Parse(summary!);
- var dict = new Dictionary();
- 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
- {
- ["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("", resultHeaders.GetProperty("Authorization").GetString());
- }
-
- [Fact]
- public void HeaderRedaction_CaseInsensitive_LowercaseAuthorization_Redacted()
- {
- var headers = new Dictionary
- {
- ["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("", resultHeaders.GetProperty("authorization").GetString());
- }
-
- [Fact]
- public void HeaderRedaction_CustomRedactList_RedactsCustomHeaderName()
- {
- var opts = new AuditLogOptions
- {
- HeaderRedactList = new List { "X-Custom-Secret" },
- };
- var headers = new Dictionary
- {
- ["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("", 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
- {
- ["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("", 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 "" — then truncation caps the
- // re-serialised string. Result must:
- // * carry "" (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
- {
- ["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("", result.RequestSummary);
- Assert.DoesNotContain(secret, result.RequestSummary);
- Assert.True(result.PayloadTruncated);
- }
-
- ///
- /// IOptionsMonitor test double — returns the same snapshot on every read,
- /// no change-token plumbing required for these tests.
- ///
- private sealed class StaticMonitor : IOptionsMonitor
- {
- private readonly AuditLogOptions _value;
- public StaticMonitor(AuditLogOptions value) => _value = value;
- public AuditLogOptions CurrentValue => _value;
- public AuditLogOptions Get(string? name) => _value;
- public IDisposable? OnChange(Action listener) => null;
- }
-}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/InboundChannelCapTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/InboundChannelCapTests.cs
deleted file mode 100644
index f10308c9..00000000
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/InboundChannelCapTests.cs
+++ /dev/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;
-
-///
-/// 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.
-///
-///
-/// Uses a file-local helper mirroring the
-/// convention in the sibling Payload tests (TruncationTests,
-/// FilterIntegrationTests, BodyRegexRedactionTests, etc.) — the
-/// TestOptionsMonitor<T> helper referenced by the plan is a
-/// private nested class inside AuditLogOptionsBindingTests and thus
-/// not reachable from this file.
-///
-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.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.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.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.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);
- }
-
- ///
- /// IOptionsMonitor test double — returns the same snapshot on every read,
- /// no change-token plumbing required for these tests. Mirrors the helper
- /// used in TruncationTests, FilterIntegrationTests, etc.
- ///
- private sealed class StaticMonitor : IOptionsMonitor
- {
- private readonly AuditLogOptions _value;
-
- public StaticMonitor(AuditLogOptions value) => _value = value;
-
- public AuditLogOptions CurrentValue => _value;
-
- public AuditLogOptions Get(string? name) => _value;
-
- public IDisposable? OnChange(Action listener) => null;
- }
-}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/PayloadFilterContractTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/PayloadFilterContractTests.cs
deleted file mode 100644
index 22368d99..00000000
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/PayloadFilterContractTests.cs
+++ /dev/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;
-
-///
-/// Bundle A (M5-T1) contract test for . 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.
-///
-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);
- }
-}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/RedactionSafetyNetTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/RedactionSafetyNetTests.cs
deleted file mode 100644
index 515efdf7..00000000
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/RedactionSafetyNetTests.cs
+++ /dev/null
@@ -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;
-
-///
-/// Bundle D (M5-T10) safety-net edge cases for
-/// . Bundle B already pinned the
-/// happy-path safety net (catastrophic-backtracking timeout →
-/// <redacted: redactor error> + 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.
-///
-///
-///
-/// The invariants under test:
-///
-///
-/// - An UNCOMPILABLE pattern (e.g. [unclosed) 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
-/// the counter tracks RUNTIME redaction failures only.
-/// - 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.
-/// - 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.
-///
-///
-public class RedactionSafetyNetTests
-{
- private static IOptionsMonitor 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 { badPattern, goodPattern },
- };
- var counter = new CountingRedactionFailureCounter();
- var spy = new SpyLogger();
- 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("", 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 { validA, evil, validB },
- };
- var counter = new CountingRedactionFailureCounter();
- var filter = new DefaultAuditPayloadFilter(
- Monitor(opts),
- NullLogger.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("", 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("", 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 { "\"password\":\\s*\"[^\"]*\"" },
- });
- var counter = new CountingRedactionFailureCounter();
- var spy = new SpyLogger();
- 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
- {
- "\"password\":\\s*\"[^\"]*\"",
- "[unclosed",
- },
- });
-
- var after = filter.Apply(evt);
- Assert.NotNull(after.RequestSummary);
- Assert.DoesNotContain("hunter2", after.RequestSummary);
- Assert.Contains("", 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"));
- }
-
- /// Counts calls.
- private sealed class CountingRedactionFailureCounter : IAuditRedactionFailureCounter
- {
- private int _count;
- public int Count => _count;
- public void Increment() => System.Threading.Interlocked.Increment(ref _count);
- }
-
- /// IOptionsMonitor test double — returns the same snapshot on every read.
- private sealed class StaticMonitor : IOptionsMonitor
- {
- private readonly AuditLogOptions _value;
- public StaticMonitor(AuditLogOptions value) => _value = value;
- public AuditLogOptions CurrentValue => _value;
- public AuditLogOptions Get(string? name) => _value;
- public IDisposable? OnChange(Action listener) => null;
- }
-
- ///
- /// IOptionsMonitor test double that supports a live —
- /// mirrors the helper used in
- /// ;
- /// kept private here so the safety-net test file remains self-contained.
- ///
- private sealed class MutableMonitor : IOptionsMonitor
- {
- private AuditLogOptions _current;
- public MutableMonitor(AuditLogOptions initial) => _current = initial;
- public AuditLogOptions CurrentValue => _current;
- public AuditLogOptions Get(string? name) => _current;
- public IDisposable? OnChange(Action listener) => null;
- public void Set(AuditLogOptions value) => _current = value;
- }
-
- ///
- /// 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.
- ///
- private sealed class SpyLogger : ILogger
- {
- private readonly List _entries = new();
-
- public IReadOnlyList Entries
- {
- get { lock (_entries) return _entries.ToArray(); }
- }
-
- public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance;
- public bool IsEnabled(LogLevel logLevel) => true;
- public void Log(
- LogLevel logLevel,
- EventId eventId,
- TState state,
- Exception? exception,
- Func 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);
-}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/SqlParamRedactionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/SqlParamRedactionTests.cs
deleted file mode 100644
index 17d12bbb..00000000
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/SqlParamRedactionTests.cs
+++ /dev/null
@@ -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;
-
-///
-/// Bundle B (M5-T5) tests for SQL parameter redaction in
-/// . M4 Bundle A's
-/// AuditingDbCommand emits RequestSummary as
-/// {"sql":"...","parameters":{"@name":"value", ...}}; the SQL-parameter
-/// redactor parses this shape on
-/// rows, replaces values whose key
-/// matches the configured case-insensitive regex with <redacted>,
-/// and re-serialises. Default behaviour with no opt-in: parameter values are
-/// captured verbatim. Connection lookup uses the connection-name prefix of
-/// (everything before the first .) so
-/// the same per-connection regex applies regardless of the SQL-snippet suffix
-/// that AuditingDbCommand appends to disambiguate rows.
-///
-public class SqlParamRedactionTests
-{
- private static IOptionsMonitor Monitor(AuditLogOptions? opts = null) =>
- new StaticMonitor(opts ?? new AuditLogOptions());
-
- private static DefaultAuditPayloadFilter Filter(AuditLogOptions? opts = null) =>
- new(Monitor(opts), NullLogger.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,
- };
-
- ///
- /// Build a RequestSummary in the exact shape M4's AuditingDbCommand
- /// emits — hand-rolled JSON with "sql" + "parameters" keys.
- /// Tests depend on this format; if AuditingDbCommand ever changes, this
- /// helper updates in lockstep.
- ///
- 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
- {
- ["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\":\"\"", result.RequestSummary);
- Assert.Contains("\"@apikey\":\"\"", 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
- {
- ["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\":\"\"", 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
- {
- ["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
- {
- ["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("", aResult.RequestSummary!);
- Assert.DoesNotContain("the-secret", aResult.RequestSummary!);
-
- Assert.Equal(input, bResult.RequestSummary);
- }
-
- /// IOptionsMonitor test double.
- private sealed class StaticMonitor : IOptionsMonitor
- {
- private readonly AuditLogOptions _value;
- public StaticMonitor(AuditLogOptions value) => _value = value;
- public AuditLogOptions CurrentValue => _value;
- public AuditLogOptions Get(string? name) => _value;
- public IDisposable? OnChange(Action listener) => null;
- }
-}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/TruncationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/TruncationTests.cs
deleted file mode 100644
index dc75c917..00000000
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/TruncationTests.cs
+++ /dev/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;
-
-///
-/// Bundle A (M5-T2) tests for truncation.
-/// The filter caps RequestSummary / ResponseSummary / ErrorDetail / Extra at
-/// (8 KiB) on success rows and
-/// (64 KiB) on error rows. "Error
-/// row" = NOT IN (Delivered,
-/// Submitted, Forwarded). Truncation must respect UTF-8 character
-/// boundaries (never split a multi-byte sequence mid-character) and must set
-/// true when any field is shortened.
-///
-public class TruncationTests
-{
- private static IOptionsMonitor Monitor(AuditLogOptions? opts = null)
- {
- var snapshot = opts ?? new AuditLogOptions();
- return new StaticMonitor(snapshot);
- }
-
- private static DefaultAuditPayloadFilter Filter(AuditLogOptions? opts = null) =>
- new(Monitor(opts), NullLogger.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);
- }
-
- ///
- /// 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).
- ///
- private sealed class StaticMonitor : IOptionsMonitor
- {
- private readonly AuditLogOptions _value;
-
- public StaticMonitor(AuditLogOptions value) => _value = value;
-
- public AuditLogOptions CurrentValue => _value;
-
- public AuditLogOptions Get(string? name) => _value;
-
- public IDisposable? OnChange(Action listener) => null;
- }
-}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/FallbackAuditWriterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/FallbackAuditWriterTests.cs
index 48119085..2047de9c 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/FallbackAuditWriterTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/FallbackAuditWriterTests.cs
@@ -1,7 +1,9 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
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.Types.Enums;
@@ -15,17 +17,13 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
///
public class FallbackAuditWriterTests
{
- private static AuditEvent NewEvent(string? target = null) => new()
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- Target = target,
- PayloadTruncated = false,
- ForwardState = AuditForwardState.Pending,
- };
+ private static AuditEvent NewEvent(string? target = null) => ScadaBridgeAuditEventFactory.Create(
+ eventId: Guid.NewGuid(),
+ occurredAtUtc: DateTime.UtcNow,
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ target: target);
/// Flip-switch primary writer mock.
private sealed class FlipSwitchPrimary : IAuditWriter
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/RingBufferFallbackTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/RingBufferFallbackTests.cs
index dbb175ab..995f6f17 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/RingBufferFallbackTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/RingBufferFallbackTests.cs
@@ -1,5 +1,6 @@
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;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
@@ -13,17 +14,13 @@ public class RingBufferFallbackTests
{
private static AuditEvent NewEvent(string? target = null)
{
- return new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- Target = target,
- PayloadTruncated = false,
- ForwardState = AuditForwardState.Pending,
- };
+ return ScadaBridgeAuditEventFactory.Create(
+ eventId: Guid.NewGuid(),
+ occurredAtUtc: DateTime.UtcNow,
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ target: target);
}
[Fact]
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterBacklogStatsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterBacklogStatsTests.cs
index d9b81cda..49195d1c 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterBacklogStatsTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterBacklogStatsTests.cs
@@ -2,7 +2,8 @@ using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
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.Audit;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
@@ -44,15 +45,12 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
new FakeNodeIdentityProvider());
}
- private static AuditEvent NewEvent(DateTime? occurredAtUtc = null) => new()
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = occurredAtUtc ?? DateTime.UtcNow,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- PayloadTruncated = false,
- };
+ private static AuditEvent NewEvent(DateTime? occurredAtUtc = null) => ScadaBridgeAuditEventFactory.Create(
+ eventId: Guid.NewGuid(),
+ occurredAtUtc: occurredAtUtc ?? DateTime.UtcNow,
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered);
[Fact]
public async Task EmptyDb_Returns_Zero_Null_AndZeroBytes()
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs
index f6bb6e08..7b91320f 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs
@@ -3,7 +3,8 @@ using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
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.Audit;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
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;
// without the ALTER it would fail with "no such column: ExecutionId"
// and — because audit writes are best-effort — silently drop the row.
- var evt = new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- PayloadTruncated = false,
- ExecutionId = executionId,
- };
+ var evt = ScadaBridgeAuditEventFactory.Create(
+ eventId: Guid.NewGuid(),
+ occurredAtUtc: DateTime.UtcNow,
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ executionId: executionId);
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
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
@@ -343,23 +341,20 @@ public class SqliteAuditWriterSchemaTests
// round-trip; without the ALTER it would fail with "no such column:
// ParentExecutionId" and — because audit writes are best-effort —
// silently drop the row.
- var evt = new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- PayloadTruncated = false,
- ExecutionId = executionId,
- ParentExecutionId = parentExecutionId,
- };
+ var evt = ScadaBridgeAuditEventFactory.Create(
+ eventId: Guid.NewGuid(),
+ occurredAtUtc: DateTime.UtcNow,
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ executionId: executionId,
+ parentExecutionId: parentExecutionId);
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
- Assert.Equal(executionId, row.ExecutionId);
- Assert.Equal(parentExecutionId, row.ParentExecutionId);
+ Assert.Equal(executionId, row.AsRow().ExecutionId);
+ Assert.Equal(parentExecutionId, row.AsRow().ParentExecutionId);
}
// 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));
await using (writer)
{
- var evt = new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.Notification,
- Kind = AuditKind.NotifySend,
- Status = AuditStatus.Submitted,
- PayloadTruncated = false,
- // ParentExecutionId left null
- };
+ var evt = ScadaBridgeAuditEventFactory.Create(
+ eventId: Guid.NewGuid(),
+ occurredAtUtc: DateTime.UtcNow,
+ channel: AuditChannel.Notification,
+ kind: AuditKind.NotifySend,
+ status: AuditStatus.Submitted);
+ // ParentExecutionId left null (not a factory arg → defaults null)
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
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;
// without the ALTER it would fail with "no such column: SourceNode"
// and — because audit writes are best-effort — silently drop the row.
- var evt = new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- PayloadTruncated = false,
- SourceNode = "node-a",
- };
+ var evt = ScadaBridgeAuditEventFactory.Create(
+ eventId: Guid.NewGuid(),
+ occurredAtUtc: DateTime.UtcNow,
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ sourceNode: "node-a");
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
@@ -504,16 +493,13 @@ public class SqliteAuditWriterSchemaTests
var (writer, _) = CreateWriter(nameof(WriteAsync_persists_SourceNode_field));
await using (writer)
{
- var evt = new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- PayloadTruncated = false,
- SourceNode = "node-a",
- };
+ var evt = ScadaBridgeAuditEventFactory.Create(
+ eventId: Guid.NewGuid(),
+ occurredAtUtc: DateTime.UtcNow,
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ sourceNode: "node-a");
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
@@ -528,16 +514,13 @@ public class SqliteAuditWriterSchemaTests
var (writer, _) = CreateWriter(nameof(WriteAsync_persists_null_SourceNode));
await using (writer)
{
- var evt = new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.Notification,
- Kind = AuditKind.NotifySend,
- Status = AuditStatus.Submitted,
- PayloadTruncated = false,
- // SourceNode left null
- };
+ var evt = ScadaBridgeAuditEventFactory.Create(
+ eventId: Guid.NewGuid(),
+ occurredAtUtc: DateTime.UtcNow,
+ channel: AuditChannel.Notification,
+ kind: AuditKind.NotifySend,
+ status: AuditStatus.Submitted);
+ // SourceNode left null (not a factory arg → defaults null)
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs
index c7a671fc..650e859b 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs
@@ -1,10 +1,11 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
+using ZB.MOM.WW.Audit;
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.Services;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
@@ -51,18 +52,23 @@ public class SqliteAuditWriterWriteTests
return connection;
}
- private static AuditEvent NewEvent(Guid? id = null, DateTime? occurredAtUtc = null)
- {
- return new AuditEvent
- {
- EventId = id ?? Guid.NewGuid(),
- OccurredAtUtc = occurredAtUtc ?? DateTime.UtcNow,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- PayloadTruncated = false,
- };
- }
+ // 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
+ // (defaulting ForwardState=Pending) on INSERT and recomposes the canonical record
+ // on read. ExecutionId/SourceNode ride through DetailsJson / the top-level field.
+ private static AuditEvent NewEvent(
+ Guid? id = null,
+ DateTime? occurredAtUtc = null,
+ Guid? executionId = null,
+ string? sourceNode = null)
+ => ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ eventId: id ?? Guid.NewGuid(),
+ occurredAtUtc: occurredAtUtc ?? DateTime.UtcNow,
+ executionId: executionId,
+ sourceNode: sourceNode);
[Fact]
public async Task WriteAsync_FreshEvent_PersistsWithForwardStatePending()
@@ -134,7 +140,10 @@ public class SqliteAuditWriterWriteTests
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_ForcesForwardStatePending_IfNull));
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);
using var connection = OpenVerifierConnection(dataSource);
@@ -372,13 +381,13 @@ public class SqliteAuditWriterWriteTests
await using var _w = writer;
var executionId = Guid.NewGuid();
- var evt = NewEvent() with { ExecutionId = executionId };
+ var evt = NewEvent(executionId: executionId);
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
- Assert.Equal(executionId, row.ExecutionId);
+ Assert.Equal(executionId, row.AsRow().ExecutionId);
}
[Fact]
@@ -387,13 +396,13 @@ public class SqliteAuditWriterWriteTests
var (writer, _) = CreateWriter(nameof(WriteAsync_NullExecutionId_RoundTripsAsNull));
await using var _w = writer;
- var evt = NewEvent() with { ExecutionId = null };
+ var evt = NewEvent(); // executionId defaults to null
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
- Assert.Null(row.ExecutionId);
+ Assert.Null(row.AsRow().ExecutionId);
}
// ----- SourceNode stamping (Tasks 11/12) ----- //
@@ -425,7 +434,7 @@ public class SqliteAuditWriterWriteTests
// Reconciled rows from another node arrive with their origin's
// 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);
var rows = await writer.ReadPendingAsync(limit: 10);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs
index e73e82c5..36d62369 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs
@@ -3,6 +3,7 @@ using NSubstitute;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
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.Enums;
@@ -67,11 +68,11 @@ public class CachedCallLifecycleBridgeTests
httpStatus: 503));
var packet = Assert.Single(captured);
- Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
- Assert.Equal(AuditStatus.Attempted, packet.Audit.Status);
- Assert.Equal(503, packet.Audit.HttpStatus);
- Assert.Equal("HTTP 503", packet.Audit.ErrorMessage);
- Assert.Equal(_id.Value, packet.Audit.CorrelationId);
+ Assert.Equal(AuditKind.ApiCallCached, packet.Audit.AsRow().Kind);
+ Assert.Equal(AuditStatus.Attempted, packet.Audit.AsRow().Status);
+ Assert.Equal(503, packet.Audit.AsRow().HttpStatus);
+ Assert.Equal("HTTP 503", packet.Audit.AsRow().ErrorMessage);
+ Assert.Equal(_id.Value, packet.Audit.AsRow().CorrelationId);
Assert.Equal("Attempted", packet.Operational.Status);
Assert.Equal(2, packet.Operational.RetryCount);
Assert.Null(packet.Operational.TerminalAtUtc);
@@ -90,17 +91,17 @@ public class CachedCallLifecycleBridgeTests
Assert.Equal(2, captured.Count);
var attempted = captured[0];
- Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.Kind);
- Assert.Equal(AuditStatus.Attempted, attempted.Audit.Status);
+ Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.AsRow().Kind);
+ Assert.Equal(AuditStatus.Attempted, attempted.Audit.AsRow().Status);
Assert.Equal("Attempted", attempted.Operational.Status);
Assert.Null(attempted.Operational.TerminalAtUtc);
var resolve = captured[1];
- Assert.Equal(AuditKind.CachedResolve, resolve.Audit.Kind);
- Assert.Equal(AuditStatus.Delivered, resolve.Audit.Status);
+ Assert.Equal(AuditKind.CachedResolve, resolve.Audit.AsRow().Kind);
+ Assert.Equal(AuditStatus.Delivered, resolve.Audit.AsRow().Status);
Assert.Equal("Delivered", resolve.Operational.Status);
Assert.NotNull(resolve.Operational.TerminalAtUtc);
- Assert.Equal(_id.Value, resolve.Audit.CorrelationId);
+ Assert.Equal(_id.Value, resolve.Audit.AsRow().CorrelationId);
}
[Fact]
@@ -116,9 +117,9 @@ public class CachedCallLifecycleBridgeTests
lastError: "Permanent failure (handler returned false)"));
Assert.Equal(2, captured.Count);
- Assert.Equal(AuditKind.ApiCallCached, captured[0].Audit.Kind);
- Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.Kind);
- Assert.Equal(AuditStatus.Parked, captured[1].Audit.Status);
+ Assert.Equal(AuditKind.ApiCallCached, captured[0].Audit.AsRow().Kind);
+ Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.AsRow().Kind);
+ Assert.Equal(AuditStatus.Parked, captured[1].Audit.AsRow().Status);
Assert.Equal("Parked", captured[1].Operational.Status);
}
@@ -133,8 +134,8 @@ public class CachedCallLifecycleBridgeTests
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.ParkedMaxRetries));
Assert.Equal(2, captured.Count);
- Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.Kind);
- Assert.Equal(AuditStatus.Parked, captured[1].Audit.Status);
+ Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.AsRow().Kind);
+ Assert.Equal(AuditStatus.Parked, captured[1].Audit.AsRow().Status);
}
[Fact]
@@ -149,11 +150,11 @@ public class CachedCallLifecycleBridgeTests
CachedCallAttemptOutcome.Delivered, channel: "DbOutbound"));
Assert.Equal(2, captured.Count);
- Assert.Equal(AuditKind.DbWriteCached, captured[0].Audit.Kind);
- Assert.Equal(AuditChannel.DbOutbound, captured[0].Audit.Channel);
+ Assert.Equal(AuditKind.DbWriteCached, captured[0].Audit.AsRow().Kind);
+ Assert.Equal(AuditChannel.DbOutbound, captured[0].Audit.AsRow().Channel);
Assert.Equal("DbOutbound", captured[0].Operational.Channel);
- Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.Kind);
- Assert.Equal(AuditChannel.DbOutbound, captured[1].Audit.Channel);
+ Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.AsRow().Kind);
+ Assert.Equal(AuditChannel.DbOutbound, captured[1].Audit.AsRow().Channel);
}
[Fact]
@@ -184,11 +185,11 @@ public class CachedCallLifecycleBridgeTests
httpStatus: 500));
Assert.NotNull(captured);
- Assert.Equal("site-77", captured!.Audit.SourceSiteId);
- Assert.Equal("Plant.Pump42", captured.Audit.SourceInstanceId);
- Assert.Equal("ERP.GetOrder", captured.Audit.Target);
- Assert.Equal(42, captured.Audit.DurationMs);
- Assert.Equal(_id.Value, captured.Audit.CorrelationId);
+ Assert.Equal("site-77", captured!.Audit.AsRow().SourceSiteId);
+ Assert.Equal("Plant.Pump42", captured.Audit.AsRow().SourceInstanceId);
+ Assert.Equal("ERP.GetOrder", captured.Audit.AsRow().Target);
+ Assert.Equal(42, captured.Audit.AsRow().DurationMs);
+ Assert.Equal(_id.Value, captured.Audit.AsRow().CorrelationId);
}
// ── Audit Log #23 (ExecutionId Task 4): ExecutionId / SourceScript ──
@@ -212,9 +213,9 @@ public class CachedCallLifecycleBridgeTests
sourceScript: "Plant.Pump42/OnTick"));
var packet = Assert.Single(captured);
- Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
- Assert.Equal(executionId, packet.Audit.ExecutionId);
- Assert.Equal("Plant.Pump42/OnTick", packet.Audit.SourceScript);
+ Assert.Equal(AuditKind.ApiCallCached, packet.Audit.AsRow().Kind);
+ Assert.Equal(executionId, packet.Audit.AsRow().ExecutionId);
+ Assert.Equal("Plant.Pump42/OnTick", packet.Audit.AsRow().SourceScript);
}
[Fact]
@@ -235,13 +236,13 @@ public class CachedCallLifecycleBridgeTests
sourceScript: "Plant.Tank/OnAlarm"));
Assert.Equal(2, captured.Count);
- var resolve = Assert.Single(captured, p => p.Audit.Kind == AuditKind.CachedResolve);
- Assert.Equal(executionId, resolve.Audit.ExecutionId);
- Assert.Equal("Plant.Tank/OnAlarm", resolve.Audit.SourceScript);
+ var resolve = Assert.Single(captured, p => p.Audit.AsRow().Kind == AuditKind.CachedResolve);
+ Assert.Equal(executionId, resolve.Audit.AsRow().ExecutionId);
+ Assert.Equal("Plant.Tank/OnAlarm", resolve.Audit.AsRow().SourceScript);
- var attempted = Assert.Single(captured, p => p.Audit.Kind == AuditKind.DbWriteCached);
- Assert.Equal(executionId, attempted.Audit.ExecutionId);
- Assert.Equal("Plant.Tank/OnAlarm", attempted.Audit.SourceScript);
+ var attempted = Assert.Single(captured, p => p.Audit.AsRow().Kind == AuditKind.DbWriteCached);
+ Assert.Equal(executionId, attempted.Audit.AsRow().ExecutionId);
+ Assert.Equal("Plant.Tank/OnAlarm", attempted.Audit.AsRow().SourceScript);
}
[Fact]
@@ -258,8 +259,8 @@ public class CachedCallLifecycleBridgeTests
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure));
Assert.NotNull(captured);
- Assert.Null(captured!.Audit.ExecutionId);
- Assert.Null(captured.Audit.SourceScript);
+ Assert.Null(captured!.Audit.AsRow().ExecutionId);
+ Assert.Null(captured.Audit.AsRow().SourceScript);
}
// ── Audit Log #23 (ParentExecutionId Task 6): ParentExecutionId ──
@@ -282,8 +283,8 @@ public class CachedCallLifecycleBridgeTests
parentExecutionId: parentExecutionId));
var packet = Assert.Single(captured);
- Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
- Assert.Equal(parentExecutionId, packet.Audit.ParentExecutionId);
+ Assert.Equal(AuditKind.ApiCallCached, packet.Audit.AsRow().Kind);
+ Assert.Equal(parentExecutionId, packet.Audit.AsRow().ParentExecutionId);
}
[Fact]
@@ -304,11 +305,11 @@ public class CachedCallLifecycleBridgeTests
parentExecutionId: parentExecutionId));
Assert.Equal(2, captured.Count);
- var resolve = Assert.Single(captured, p => p.Audit.Kind == AuditKind.CachedResolve);
- Assert.Equal(parentExecutionId, resolve.Audit.ParentExecutionId);
+ var resolve = Assert.Single(captured, p => p.Audit.AsRow().Kind == AuditKind.CachedResolve);
+ Assert.Equal(parentExecutionId, resolve.Audit.AsRow().ParentExecutionId);
- var attempted = Assert.Single(captured, p => p.Audit.Kind == AuditKind.DbWriteCached);
- Assert.Equal(parentExecutionId, attempted.Audit.ParentExecutionId);
+ var attempted = Assert.Single(captured, p => p.Audit.AsRow().Kind == AuditKind.DbWriteCached);
+ Assert.Equal(parentExecutionId, attempted.Audit.AsRow().ParentExecutionId);
}
[Fact]
@@ -325,7 +326,7 @@ public class CachedCallLifecycleBridgeTests
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure));
Assert.NotNull(captured);
- Assert.Null(captured!.Audit.ParentExecutionId);
+ Assert.Null(captured!.Audit.AsRow().ParentExecutionId);
}
// ── SourceNode-stamping (Task 14) ──
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/CachedCallTelemetryForwarderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/CachedCallTelemetryForwarderTests.cs
index ebb51aa0..d16bb5cc 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/CachedCallTelemetryForwarderTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/CachedCallTelemetryForwarderTests.cs
@@ -2,7 +2,9 @@ using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
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.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
@@ -32,20 +34,17 @@ public class CachedCallTelemetryForwarderTests
private CachedCallTelemetry SubmitPacket() =>
new(
- Audit: new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = _now,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.CachedSubmit,
- CorrelationId = _id.Value,
- SourceSiteId = "site-1",
- SourceInstanceId = "inst-1",
- SourceScript = "ScriptActor:doStuff",
- Target = "ERP.GetOrder",
- Status = AuditStatus.Submitted,
- ForwardState = AuditForwardState.Pending,
- },
+ Audit: ScadaBridgeAuditEventFactory.Create(
+ eventId: Guid.NewGuid(),
+ occurredAtUtc: _now,
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.CachedSubmit,
+ correlationId: _id.Value,
+ sourceSiteId: "site-1",
+ sourceInstanceId: "inst-1",
+ sourceScript: "ScriptActor:doStuff",
+ target: "ERP.GetOrder",
+ status: AuditStatus.Submitted),
Operational: new SiteCallOperational(
TrackedOperationId: _id,
Channel: "ApiOutbound",
@@ -62,20 +61,17 @@ public class CachedCallTelemetryForwarderTests
private CachedCallTelemetry AttemptedPacket(int retryCount = 1, string? lastError = "HTTP 500", int? httpStatus = 500) =>
new(
- Audit: new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = _now,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCallCached,
- CorrelationId = _id.Value,
- SourceSiteId = "site-1",
- Target = "ERP.GetOrder",
- Status = AuditStatus.Attempted,
- HttpStatus = httpStatus,
- ErrorMessage = lastError,
- ForwardState = AuditForwardState.Pending,
- },
+ Audit: ScadaBridgeAuditEventFactory.Create(
+ eventId: Guid.NewGuid(),
+ occurredAtUtc: _now,
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCallCached,
+ correlationId: _id.Value,
+ sourceSiteId: "site-1",
+ target: "ERP.GetOrder",
+ status: AuditStatus.Attempted,
+ httpStatus: httpStatus,
+ errorMessage: lastError),
Operational: new SiteCallOperational(
TrackedOperationId: _id,
Channel: "ApiOutbound",
@@ -92,18 +88,15 @@ public class CachedCallTelemetryForwarderTests
private CachedCallTelemetry ResolvePacket(string status = "Delivered") =>
new(
- Audit: new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = _now,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.CachedResolve,
- CorrelationId = _id.Value,
- SourceSiteId = "site-1",
- Target = "ERP.GetOrder",
- Status = Enum.Parse(status),
- ForwardState = AuditForwardState.Pending,
- },
+ Audit: ScadaBridgeAuditEventFactory.Create(
+ eventId: Guid.NewGuid(),
+ occurredAtUtc: _now,
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.CachedResolve,
+ correlationId: _id.Value,
+ sourceSiteId: "site-1",
+ target: "ERP.GetOrder",
+ status: Enum.Parse(status)),
Operational: new SiteCallOperational(
TrackedOperationId: _id,
Channel: "ApiOutbound",
@@ -130,8 +123,8 @@ public class CachedCallTelemetryForwarderTests
await _writer.Received(1).WriteAsync(
Arg.Is(e =>
e.EventId == packet.Audit.EventId
- && e.Kind == AuditKind.CachedSubmit
- && e.Status == AuditStatus.Submitted),
+ && e.AsRow().Kind == AuditKind.CachedSubmit
+ && e.AsRow().Status == AuditStatus.Submitted),
Arg.Any());
// Tracking row: insert-if-not-exists with kind discriminator.
@@ -165,8 +158,8 @@ public class CachedCallTelemetryForwarderTests
await _writer.Received(1).WriteAsync(
Arg.Is(e =>
e.EventId == packet.Audit.EventId
- && e.Kind == AuditKind.ApiCallCached
- && e.Status == AuditStatus.Attempted),
+ && e.AsRow().Kind == AuditKind.ApiCallCached
+ && e.AsRow().Status == AuditStatus.Attempted),
Arg.Any());
await _tracking.Received(1).RecordAttemptAsync(
@@ -188,8 +181,8 @@ public class CachedCallTelemetryForwarderTests
await _writer.Received(1).WriteAsync(
Arg.Is(e =>
e.EventId == packet.Audit.EventId
- && e.Kind == AuditKind.CachedResolve
- && e.Status == AuditStatus.Delivered),
+ && e.AsRow().Kind == AuditKind.CachedResolve
+ && e.AsRow().Status == AuditStatus.Delivered),
Arg.Any());
await _tracking.Received(1).RecordTerminalAsync(
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/ClusterClientSiteAuditClientTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/ClusterClientSiteAuditClientTests.cs
index 3c503e08..404f5a10 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/ClusterClientSiteAuditClientTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/ClusterClientSiteAuditClientTests.cs
@@ -2,7 +2,8 @@ using Akka.Actor;
using Akka.TestKit.Xunit2;
using Google.Protobuf.WellKnownTypes;
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.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
@@ -29,16 +30,13 @@ public class ClusterClientSiteAuditClientTests : TestKit
/// Short Ask timeout so the timeout test completes quickly.
private static readonly TimeSpan AskTimeout = TimeSpan.FromMilliseconds(500);
- private static AuditEvent NewEvent(Guid? id = null) => new()
- {
- EventId = id ?? Guid.NewGuid(),
- OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- SourceSiteId = "site-1",
- ForwardState = AuditForwardState.Pending,
- };
+ private static AuditEvent NewEvent(Guid? id = null) => ScadaBridgeAuditEventFactory.Create(
+ eventId: id ?? Guid.NewGuid(),
+ occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ sourceSiteId: "site-1");
private static AuditEventBatch BatchOf(IEnumerable events)
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/SiteAuditTelemetryActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/SiteAuditTelemetryActorTests.cs
index 6900d201..1aad60dd 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/SiteAuditTelemetryActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/SiteAuditTelemetryActorTests.cs
@@ -6,7 +6,8 @@ using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
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.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
@@ -66,16 +67,13 @@ public class SiteAuditTelemetryActorTests : TestKit
NullLogger.Instance,
(IOperationTrackingStore?)_trackingStore)));
- private static AuditEvent NewEvent(Guid? id = null) => new()
- {
- EventId = id ?? Guid.NewGuid(),
- OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- SourceSiteId = "site-1",
- ForwardState = AuditForwardState.Pending,
- };
+ private static AuditEvent NewEvent(Guid? id = null) => ScadaBridgeAuditEventFactory.Create(
+ eventId: id ?? Guid.NewGuid(),
+ occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ sourceSiteId: "site-1");
private static IngestAck AckAll(IReadOnlyList events)
{
@@ -265,18 +263,18 @@ public class SiteAuditTelemetryActorTests : TestKit
AuditKind kind = AuditKind.CachedSubmit,
Guid? eventId = null,
Guid? correlationId = null,
- string sourceSiteId = "site-1") => new()
- {
- EventId = eventId ?? Guid.NewGuid(),
- OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
- Channel = AuditChannel.ApiOutbound,
- Kind = kind,
- Status = AuditStatus.Submitted,
- SourceSiteId = sourceSiteId,
- Target = "ERP.GetOrder",
- CorrelationId = correlationId ?? Guid.NewGuid(),
- ForwardState = AuditForwardState.Pending,
- };
+ string sourceSiteId = "site-1") =>
+ // C3 (Task 2.5): canonical record via the shared factory; ForwardState is
+ // no longer a record field (the SQLite shim defaults it on INSERT).
+ ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: kind,
+ status: AuditStatus.Submitted,
+ eventId: eventId ?? Guid.NewGuid(),
+ occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
+ target: "ERP.GetOrder",
+ sourceSiteId: sourceSiteId,
+ correlationId: correlationId ?? Guid.NewGuid());
private static TrackingStatusSnapshot NewSnapshot(
TrackedOperationId id,
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs
index f21bce0e..648f20d2 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs
@@ -13,9 +13,9 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
+using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Audit;
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.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -38,17 +38,17 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Audit;
///
public class AuditExportEndpointsTests
{
- private static AuditEvent SampleEvent() => new()
- {
- EventId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
- OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
- IngestedAtUtc = null,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- SourceSiteId = "plant-a",
- Status = AuditStatus.Delivered,
- HttpStatus = 200,
- };
+ // C3 (Task 2.5): the export endpoint reads canonical ZB.MOM.WW.Audit.AuditEvent
+ // rows straight off IAuditLogRepository.QueryAsync; build them via the factory.
+ private static AuditEvent SampleEvent() =>
+ ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ eventId: Guid.Parse("11111111-1111-1111-1111-111111111111"),
+ occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
+ sourceSiteId: "plant-a",
+ httpStatus: 200);
///
/// Builds a tiny in-process test host that wires the export endpoint to a
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs
index bd78b13c..62c17ec3 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs
@@ -3,7 +3,7 @@ using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
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;
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
/// is clicked. It renders the offcanvas chrome (header, open/close) and delegates
-/// the body to the shared
+/// the body to the shared
/// component, which since the recent refactor owns the channel-aware bodies
/// (JSON pretty-print, SQL block for DbOutbound), redaction badges on
/// Request/Response, and conditional action buttons.
@@ -32,7 +32,7 @@ public class AuditDrilldownDrawerTests : BunitContext
JSInterop.Mode = JSRuntimeMode.Loose;
}
- private static AuditEvent MakeEvent(
+ private static AuditEventView MakeEvent(
AuditChannel channel = AuditChannel.ApiOutbound,
AuditKind kind = AuditKind.ApiCall,
AuditStatus status = AuditStatus.Delivered,
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditEventDetailTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditEventDetailTests.cs
index 6f1b4c1b..61f853d4 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditEventDetailTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditEventDetailTests.cs
@@ -3,7 +3,7 @@ using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
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;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
@@ -29,7 +29,7 @@ public class AuditEventDetailTests : BunitContext
JSInterop.Mode = JSRuntimeMode.Loose;
}
- private static AuditEvent MakeEvent(
+ private static AuditEventView MakeEvent(
AuditChannel channel = AuditChannel.ApiOutbound,
AuditKind kind = AuditKind.ApiCall,
AuditStatus status = AuditStatus.Delivered,
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs
index 391d0e04..a1f6e270 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs
@@ -4,7 +4,6 @@ using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
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.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
@@ -53,7 +52,7 @@ public class AuditFilterBarTests : BunitContext
_auditLogQueryService.GetDistinctSourceNodesAsync(Arg.Any())
.Returns(Task.FromResult>(new[] { "central-a", "central-b" }));
_auditLogQueryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
- .Returns(Task.FromResult>(Array.Empty()));
+ .Returns(Task.FromResult>(Array.Empty()));
Services.AddSingleton(_auditLogQueryService);
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs
index 3f2fa539..dcffee29 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs
@@ -4,7 +4,6 @@ using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
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.Enums;
@@ -22,7 +21,7 @@ public class AuditResultsGridTests : BunitContext
private readonly IAuditLogQueryService _service;
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()
{
EventId = Guid.NewGuid(),
@@ -53,7 +52,7 @@ public class AuditResultsGridTests : BunitContext
JSInterop.Mode = JSRuntimeMode.Loose;
}
- private void StubPage(IReadOnlyList rows)
+ private void StubPage(IReadOnlyList rows)
{
_service.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
.Returns(callInfo =>
@@ -66,7 +65,7 @@ public class AuditResultsGridTests : BunitContext
[Fact]
public void Render_TenColumns_FromStubService()
{
- StubPage(new List
+ StubPage(new List
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
@@ -112,10 +111,10 @@ public class AuditResultsGridTests : BunitContext
var target = MakeEvent(DateTime.UtcNow.AddMinutes(-5), AuditStatus.Delivered);
StubPage(new[] { target });
- AuditEvent? captured = null;
+ AuditEventView? captured = null;
var cut = Render(p => p
.Add(c => c.Filter, new AuditLogQueryFilter())
- .Add(c => c.OnRowSelected, EventCallback.Factory.Create(this, e => captured = e)));
+ .Add(c => c.OnRowSelected, EventCallback.Factory.Create(this, e => captured = e)));
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
// positioned between Site and Channel.
- StubPage(new List
+ StubPage(new List
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
@@ -148,7 +147,7 @@ public class AuditResultsGridTests : BunitContext
[Fact]
public void Render_IncludesExecutionIdColumn()
{
- StubPage(new List
+ StubPage(new List
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
@@ -191,7 +190,7 @@ public class AuditResultsGridTests : BunitContext
[Fact]
public void Render_IncludesParentExecutionIdColumn()
{
- StubPage(new List
+ StubPage(new List
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/ExecutionDetailModalTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/ExecutionDetailModalTests.cs
index f0d42700..2890924d 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/ExecutionDetailModalTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/ExecutionDetailModalTests.cs
@@ -4,7 +4,6 @@ using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
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.Enums;
@@ -36,7 +35,7 @@ public class ExecutionDetailModalTests : BunitContext
JSInterop.Mode = JSRuntimeMode.Loose;
}
- private static AuditEvent MakeEvent(
+ private static AuditEventView MakeEvent(
Guid executionId,
AuditStatus status = AuditStatus.Delivered,
AuditChannel channel = AuditChannel.ApiOutbound,
@@ -57,7 +56,7 @@ public class ExecutionDetailModalTests : BunitContext
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
};
- private void StubRows(IReadOnlyList rows)
+ private void StubRows(IReadOnlyList rows)
{
_service.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
.Returns(callInfo =>
@@ -202,7 +201,7 @@ public class ExecutionDetailModalTests : BunitContext
public void ZeroRow_ShowsFriendlyEmptyState()
{
var executionId = Guid.NewGuid();
- StubRows(Array.Empty());
+ StubRows(Array.Empty());
var cut = Render(p => p
.Add(c => c.ExecutionId, executionId)
@@ -217,7 +216,7 @@ public class ExecutionDetailModalTests : BunitContext
{
var executionId = Guid.NewGuid();
_service.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
- .Returns>>(_ => throw new InvalidOperationException("db is down"));
+ .Returns>>(_ => throw new InvalidOperationException("db is down"));
// Rendering with IsOpen=true must not throw — the modal degrades to an
// inline error banner rather than killing the SignalR circuit.
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs
index b618d9a3..2e84b8f6 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs
@@ -18,7 +18,7 @@ using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Audit;
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.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs
index 6b04d668..79858b73 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs
@@ -7,7 +7,6 @@ using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
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.Enums;
using ZB.MOM.WW.ScadaBridge.Security;
@@ -176,7 +175,7 @@ public class AuditLogPageScaffoldTests : BunitContext
var corr = Guid.Parse("11111111-2222-3333-4444-555555555555");
_queryService = Substitute.For();
_queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
- .Returns(Task.FromResult>(new List()));
+ .Returns(Task.FromResult>(new List()));
var cut = RenderAuditLogPageWithQuery($"correlationId={corr}", "Administrator");
@@ -199,7 +198,7 @@ public class AuditLogPageScaffoldTests : BunitContext
var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
_queryService = Substitute.For();
_queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
- .Returns(Task.FromResult>(new List()));
+ .Returns(Task.FromResult>(new List()));
var cut = RenderAuditLogPageWithQuery($"executionId={executionId}", "Administrator");
@@ -237,7 +236,7 @@ public class AuditLogPageScaffoldTests : BunitContext
var parentExecutionId = Guid.Parse("aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb");
_queryService = Substitute.For();
_queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
- .Returns(Task.FromResult>(new List()));
+ .Returns(Task.FromResult>(new List()));
var cut = RenderAuditLogPageWithQuery($"parentExecutionId={parentExecutionId}", "Administrator");
@@ -272,7 +271,7 @@ public class AuditLogPageScaffoldTests : BunitContext
{
_queryService = Substitute.For();
_queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
- .Returns(Task.FromResult>(new List()));
+ .Returns(Task.FromResult>(new List()));
var cut = RenderAuditLogPageWithQuery("target=ExternalSystem-Alpha", "Administrator");
@@ -290,7 +289,7 @@ public class AuditLogPageScaffoldTests : BunitContext
{
_queryService = Substitute.For();
_queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
- .Returns(Task.FromResult>(new List()));
+ .Returns(Task.FromResult>(new List()));
var cut = RenderAuditLogPageWithQuery("site=plant-a", "Administrator");
@@ -312,7 +311,7 @@ public class AuditLogPageScaffoldTests : BunitContext
// builds an AuditLogQueryFilter with Status set, and auto-loads.
_queryService = Substitute.For();
_queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
- .Returns(Task.FromResult>(new List()));
+ .Returns(Task.FromResult>(new List()));
var cut = RenderAuditLogPageWithQuery("status=Failed", "Administrator");
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/ExecutionTreePageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/ExecutionTreePageTests.cs
index e7399a0a..d8181c03 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/ExecutionTreePageTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/ExecutionTreePageTests.cs
@@ -7,7 +7,6 @@ using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
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.Enums;
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.
_queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
- .Returns(Task.FromResult>(Array.Empty()));
+ .Returns(Task.FromResult>(Array.Empty()));
// AuditEventDetail (reachable from the modal) owns a clipboard interop call.
JSInterop.Mode = JSRuntimeMode.Loose;
@@ -160,7 +159,7 @@ public class ExecutionTreePageTests : BunitContext
Node(child, root),
}));
_queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
- .Returns(Task.FromResult>(Array.Empty()));
+ .Returns(Task.FromResult>(Array.Empty()));
JSInterop.Mode = JSRuntimeMode.Loose;
var cut = RenderPage($"executionId={child}", "Administrator");
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditLogExportServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditLogExportServiceTests.cs
index 6cc7b282..c78633b0 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditLogExportServiceTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditLogExportServiceTests.cs
@@ -1,7 +1,7 @@
using System.Text;
using NSubstitute;
+using ZB.MOM.WW.Audit;
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.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -22,31 +22,22 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
///
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)
- => new()
- {
- EventId = Guid.Parse(id),
- OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
- IngestedAtUtc = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc),
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- CorrelationId = null,
- SourceSiteId = "plant-a",
- SourceInstanceId = null,
- SourceScript = null,
- Actor = null,
- Target = target,
- Status = AuditStatus.Delivered,
- HttpStatus = 200,
- DurationMs = 42,
- ErrorMessage = error,
- ErrorDetail = null,
- RequestSummary = null,
- ResponseSummary = null,
- PayloadTruncated = false,
- Extra = null,
- ForwardState = null,
- };
+ => ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ eventId: Guid.Parse(id),
+ occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
+ target: target,
+ sourceSiteId: "plant-a",
+ httpStatus: 200,
+ durationMs: 42,
+ errorMessage: error,
+ ingestedAtUtc: new DateTimeOffset(new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc)));
[Fact]
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 with embedded quote → quote must be doubled ("") and field quoted.
// ResponseSummary contains CR-LF → field must be quoted.
- var row = new AuditEvent
- {
- EventId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
- OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
- IngestedAtUtc = null,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- CorrelationId = null,
- SourceSiteId = "plant-a, secondary", // comma
- SourceInstanceId = null,
- 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 row = ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ eventId: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
+ occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
+ target: "x",
+ sourceSiteId: "plant-a, secondary", // comma
+ sourceScript: "say \"hi\"", // embedded quote
+ errorMessage: "boom\r\nthen again"); // CR-LF
var repo = Substitute.For();
repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
.Returns(
@@ -163,30 +140,12 @@ public class AuditLogExportServiceTests
public async Task ExportAsync_NullField_WrittenAsEmpty()
{
// Build a row with deliberate nulls for every nullable column.
- var row = new AuditEvent
- {
- EventId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
- OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
- IngestedAtUtc = null,
- Channel = AuditChannel.ApiOutbound,
- 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 row = ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Submitted,
+ eventId: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
+ occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc));
var repo = Substitute.For();
repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
.Returns(
@@ -278,14 +237,20 @@ public class AuditLogExportServiceTests
{
// 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.
+ 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
{
- 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 },
- 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("11111111-1111-1111-1111-111111111111", new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc)),
+ Row("22222222-2222-2222-2222-222222222222", new DateTime(2026, 5, 20, 11, 59, 0, DateTimeKind.Utc)),
};
var p2 = new List
{
- 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();
@@ -305,6 +270,8 @@ public class AuditLogExportServiceTests
Assert.Null(pagings[0].AfterEventId);
Assert.Null(pagings[0].AfterOccurredAtUtc);
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);
}
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs
index 6b760580..6cbd20fe 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs
@@ -2,8 +2,8 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
+using ZB.MOM.WW.Audit;
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.Messages.Health;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
@@ -36,18 +36,31 @@ public class AuditLogQueryServiceTests
var repo = Substitute.For();
var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.ApiOutbound });
var paging = new AuditLogPaging(PageSize: 25);
- var expected = new List
+ // C3 (Task 2.5): the repository returns canonical ZB.MOM.WW.Audit.AuditEvent rows;
+ // the service decomposes each into a flat AuditEventView for the UI.
+ var eventId = Guid.NewGuid();
+ var repoRows = new List
{
- new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered }
+ ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ eventId: eventId),
};
repo.QueryAsync(filter, paging, Arg.Any())
- .Returns(Task.FromResult>(expected));
+ .Returns(Task.FromResult>(repoRows));
var sut = new AuditLogQueryService(repo, EmptyAggregator());
var result = await sut.QueryAsync(filter, paging);
- Assert.Same(expected, result);
+ // The service projects canonical → AuditEventView (a new list), so assert on
+ // the decomposed content rather than reference identity.
+ var view = Assert.Single(result);
+ Assert.Equal(eventId, view.EventId);
+ Assert.Equal(AuditChannel.ApiOutbound, view.Channel);
+ Assert.Equal(AuditKind.ApiCall, view.Kind);
+ Assert.Equal(AuditStatus.Delivered, view.Status);
await repo.Received(1).QueryAsync(filter, paging, Arg.Any());
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/Audit/AuditEventTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/Audit/AuditEventTests.cs
deleted file mode 100644
index c7b0cf71..00000000
--- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/Audit/AuditEventTests.cs
+++ /dev/null
@@ -1,163 +0,0 @@
-using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
-using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
-
-namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities.Audit;
-
-///
-/// Verifies behaves as an init-only record:
-/// every property reads back as constructed, and with expressions
-/// produce a new instance with a single property changed.
-///
-public class AuditEventTests
-{
- [Fact]
- public void Construction_AllPropertiesReadBack()
- {
- var eventId = Guid.NewGuid();
- var occurredAt = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
- var ingestedAt = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc);
- var corrId = Guid.NewGuid();
- var execId = Guid.NewGuid();
-
- var evt = new AuditEvent
- {
- EventId = eventId,
- OccurredAtUtc = occurredAt,
- IngestedAtUtc = ingestedAt,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- CorrelationId = corrId,
- ExecutionId = execId,
- SourceSiteId = "site-01",
- SourceInstanceId = "inst-7",
- SourceScript = "OnAlarm",
- Actor = "system",
- Target = "WeatherAPI",
- Status = AuditStatus.Delivered,
- HttpStatus = 200,
- DurationMs = 42,
- ErrorMessage = null,
- ErrorDetail = null,
- RequestSummary = "GET /forecast",
- ResponseSummary = "{\"temp\":21}",
- PayloadTruncated = false,
- Extra = "{}",
- ForwardState = AuditForwardState.Forwarded
- };
-
- Assert.Equal(eventId, evt.EventId);
- Assert.Equal(occurredAt, evt.OccurredAtUtc);
- Assert.Equal(ingestedAt, evt.IngestedAtUtc);
- Assert.Equal(AuditChannel.ApiOutbound, evt.Channel);
- Assert.Equal(AuditKind.ApiCall, evt.Kind);
- Assert.Equal(corrId, evt.CorrelationId);
- Assert.Equal(execId, evt.ExecutionId);
- Assert.Equal("site-01", evt.SourceSiteId);
- Assert.Equal("inst-7", evt.SourceInstanceId);
- Assert.Equal("OnAlarm", evt.SourceScript);
- Assert.Equal("system", evt.Actor);
- Assert.Equal("WeatherAPI", evt.Target);
- Assert.Equal(AuditStatus.Delivered, evt.Status);
- Assert.Equal(200, evt.HttpStatus);
- Assert.Equal(42, evt.DurationMs);
- Assert.Null(evt.ErrorMessage);
- Assert.Null(evt.ErrorDetail);
- Assert.Equal("GET /forecast", evt.RequestSummary);
- Assert.Equal("{\"temp\":21}", evt.ResponseSummary);
- Assert.False(evt.PayloadTruncated);
- Assert.Equal("{}", evt.Extra);
- Assert.Equal(AuditForwardState.Forwarded, evt.ForwardState);
- }
-
- [Fact]
- public void NullableProperties_AcceptNull()
- {
- var evt = new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- IngestedAtUtc = null,
- Channel = AuditChannel.Notification,
- Kind = AuditKind.NotifySend,
- CorrelationId = null,
- ExecutionId = 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
- };
-
- Assert.Null(evt.IngestedAtUtc);
- Assert.Null(evt.CorrelationId);
- Assert.Null(evt.ExecutionId);
- Assert.Null(evt.SourceSiteId);
- Assert.Null(evt.SourceInstanceId);
- Assert.Null(evt.SourceScript);
- Assert.Null(evt.Actor);
- Assert.Null(evt.Target);
- Assert.Null(evt.HttpStatus);
- Assert.Null(evt.DurationMs);
- Assert.Null(evt.ErrorMessage);
- Assert.Null(evt.ErrorDetail);
- Assert.Null(evt.RequestSummary);
- Assert.Null(evt.ResponseSummary);
- Assert.Null(evt.Extra);
- Assert.Null(evt.ForwardState);
- }
-
- [Fact]
- public void AuditEvent_carries_SourceNode_through_init()
- {
- // SourceNode identifies the cluster node that emitted the event (site
- // node-a/node-b or central-a/central-b). It's an additive nullable
- // init-only property — defaults to null when omitted, round-trips its
- // value when set, and is preserved through `with` expressions.
- var evtDefault = new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Submitted,
- PayloadTruncated = false
- };
- Assert.Null(evtDefault.SourceNode);
-
- var evtStamped = evtDefault with { SourceNode = "node-a" };
- Assert.Equal("node-a", evtStamped.SourceNode);
- Assert.Null(evtDefault.SourceNode);
- }
-
- [Fact]
- public void With_ProducesNewInstance_WithSingleFieldChanged()
- {
- var original = new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Submitted,
- PayloadTruncated = false
- };
-
- var updated = original with { Status = AuditStatus.Delivered };
-
- Assert.NotSame(original, updated);
- Assert.Equal(AuditStatus.Submitted, original.Status);
- Assert.Equal(AuditStatus.Delivered, updated.Status);
- Assert.Equal(original.EventId, updated.EventId);
- Assert.NotEqual(original, updated);
- }
-}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/Audit/SiteCallTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/Audit/SiteCallTests.cs
index 9da4e3ba..5dfd8c3d 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/Audit/SiteCallTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/Audit/SiteCallTests.cs
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities.Audit;
///
/// Verifies the central operational entity carries the
/// SourceNode column (additive, nullable) through init-only construction and
-/// with expressions. Sibling to .
+/// with expressions.
///
public class SiteCallTests
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Interfaces/Services/AuditWriterContractTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Interfaces/Services/AuditWriterContractTests.cs
index 87b5a6b7..68b55218 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Interfaces/Services/AuditWriterContractTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Interfaces/Services/AuditWriterContractTests.cs
@@ -1,6 +1,7 @@
using System.Reflection;
-using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
-using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
+using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
+using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
+using ICentralAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.ICentralAuditWriter;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Interfaces.Services;
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/Integration/AuditTelemetryMessagesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/Integration/AuditTelemetryMessagesTests.cs
index 0c0ee9eb..3ea747c5 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/Integration/AuditTelemetryMessagesTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/Integration/AuditTelemetryMessagesTests.cs
@@ -1,5 +1,6 @@
-using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
+using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages.Integration;
@@ -10,15 +11,15 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages.Integration;
///
public class AuditTelemetryMessagesTests
{
- private static AuditEvent MakeEvent(Guid? id = null) => new()
- {
- EventId = id ?? Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- PayloadTruncated = false
- };
+ // C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent built via the shared
+ // factory (domain fields ride in DetailsJson). These tests assert only the
+ // envelope/response DTO behaviour, so the audit row's contents are incidental.
+ private static AuditEvent MakeEvent(Guid? id = null) =>
+ ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ eventId: id ?? Guid.NewGuid());
[Fact]
public void AuditTelemetryEnvelope_ConstructsWithThreeEvents_AndIsEnumerable()
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs
index f3ac7a3a..7b9e1979 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs
@@ -1,6 +1,7 @@
-using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
+using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages.Integration;
@@ -28,23 +29,23 @@ public class CachedCallTelemetryTests
string? errorMessage = null,
int? httpStatus = null)
{
- return new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = FixedNowUtc,
- Channel = AuditChannel.ApiOutbound,
- Kind = kind,
- CorrelationId = correlationId ?? trackedId.Value,
- SourceSiteId = SiteId,
- SourceInstanceId = InstanceName,
- SourceScript = SourceScript,
- Target = "ERP.GetOrder",
- Status = status,
- HttpStatus = httpStatus,
- ErrorMessage = errorMessage,
- PayloadTruncated = false,
- ForwardState = AuditForwardState.Pending,
- };
+ // C3 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the
+ // shared factory; the ScadaBridge domain fields ride in DetailsJson and
+ // are read back as typed properties via AsRow() in the assertions.
+ // ForwardState is no longer a packet field — it is a site-storage-only
+ // concern handled by the SQLite writer.
+ return ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: kind,
+ status: status,
+ occurredAtUtc: FixedNowUtc,
+ target: "ERP.GetOrder",
+ correlationId: correlationId ?? trackedId.Value,
+ sourceSiteId: SiteId,
+ sourceInstanceId: InstanceName,
+ sourceScript: SourceScript,
+ httpStatus: httpStatus,
+ errorMessage: errorMessage);
}
private static SiteCallOperational BuildOperational(
@@ -82,8 +83,8 @@ public class CachedCallTelemetryTests
var packet = new CachedCallTelemetry(audit, operational);
- Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind);
- Assert.Equal(AuditStatus.Submitted, packet.Audit.Status);
+ Assert.Equal(AuditKind.CachedSubmit, packet.Audit.AsRow().Kind);
+ Assert.Equal(AuditStatus.Submitted, packet.Audit.AsRow().Status);
Assert.Equal(nameof(AuditStatus.Submitted), packet.Operational.Status);
Assert.Equal(0, packet.Operational.RetryCount);
Assert.Null(packet.Operational.TerminalAtUtc);
@@ -109,13 +110,13 @@ public class CachedCallTelemetryTests
var packet = new CachedCallTelemetry(audit, operational);
- Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
- Assert.Equal(AuditStatus.Attempted, packet.Audit.Status);
+ Assert.Equal(AuditKind.ApiCallCached, packet.Audit.AsRow().Kind);
+ Assert.Equal(AuditStatus.Attempted, packet.Audit.AsRow().Status);
Assert.Equal(nameof(AuditStatus.Attempted), packet.Operational.Status);
// Retry-count alignment: the operational row carries the canonical N;
// the audit row's error/http surface the same attempt's outcome.
- Assert.Equal(packet.Audit.ErrorMessage, packet.Operational.LastError);
- Assert.Equal(packet.Audit.HttpStatus, packet.Operational.HttpStatus);
+ Assert.Equal(packet.Audit.AsRow().ErrorMessage, packet.Operational.LastError);
+ Assert.Equal(packet.Audit.AsRow().HttpStatus, packet.Operational.HttpStatus);
Assert.Equal(2, packet.Operational.RetryCount);
Assert.Null(packet.Operational.TerminalAtUtc);
}
@@ -138,8 +139,8 @@ public class CachedCallTelemetryTests
var packet = new CachedCallTelemetry(audit, operational);
- Assert.Equal(AuditKind.DbWriteCached, packet.Audit.Kind);
- Assert.Equal(AuditStatus.Attempted, packet.Audit.Status);
+ Assert.Equal(AuditKind.DbWriteCached, packet.Audit.AsRow().Kind);
+ Assert.Equal(AuditStatus.Attempted, packet.Audit.AsRow().Status);
Assert.Equal(1, packet.Operational.RetryCount);
}
@@ -161,8 +162,8 @@ public class CachedCallTelemetryTests
var packet = new CachedCallTelemetry(audit, operational);
- Assert.Equal(AuditKind.CachedResolve, packet.Audit.Kind);
- Assert.Equal(terminalStatus, packet.Audit.Status);
+ Assert.Equal(AuditKind.CachedResolve, packet.Audit.AsRow().Kind);
+ Assert.Equal(terminalStatus, packet.Audit.AsRow().Status);
Assert.Equal(terminalStatus.ToString(), packet.Operational.Status);
Assert.NotNull(packet.Operational.TerminalAtUtc);
Assert.Equal(terminalAt, packet.Operational.TerminalAtUtc);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/AuditEventDtoMapperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/AuditEventDtoMapperTests.cs
index 8a61ee98..e73d72cc 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/AuditEventDtoMapperTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/AuditEventDtoMapperTests.cs
@@ -1,5 +1,6 @@
using Google.Protobuf.WellKnownTypes;
-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.Communication.Grpc;
@@ -7,93 +8,94 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
///
/// Round-trip + edge tests for the that bridges
-/// (Commons) ↔ (proto).
-/// ForwardState is site-local and IngestedAtUtc is central-set, so neither survives
-/// the proto round-trip.
+/// the canonical ↔ (proto).
///
+///
+/// C3 (Task 2.5): the canonical record carries the ScadaBridge domain fields inside
+/// DetailsJson; the proto contract is unchanged (24-field wire). Domain fields
+/// are read back as typed properties via AsRow(). ForwardState is
+/// site-storage-only (never on the wire) and IngestedAtUtc is central-set
+/// (left null by the mapper), so neither survives the proto round-trip.
+///
public class AuditEventDtoMapperTests
{
[Fact]
public void ToDto_FromDto_Roundtrip_FullyPopulated_PreservesAllFields()
{
var occurredAt = new DateTime(2026, 5, 20, 10, 15, 30, 123, DateTimeKind.Utc);
- var ingestedAt = new DateTime(2026, 5, 20, 10, 15, 31, 0, DateTimeKind.Utc);
+ var ingestedAt = new DateTimeOffset(new DateTime(2026, 5, 20, 10, 15, 31, 0, DateTimeKind.Utc));
var correlationId = Guid.NewGuid();
var executionId = Guid.NewGuid();
var parentExecutionId = Guid.NewGuid();
var eventId = Guid.NewGuid();
- var original = new AuditEvent
- {
- EventId = eventId,
- OccurredAtUtc = occurredAt,
- IngestedAtUtc = ingestedAt,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCallCached,
- CorrelationId = correlationId,
- ExecutionId = executionId,
- ParentExecutionId = parentExecutionId,
- SourceSiteId = "site-1",
- SourceNode = "node-a",
- SourceInstanceId = "Pump01",
- SourceScript = "OnDemand",
- Actor = "design-key",
- Target = "weather-api",
- Status = AuditStatus.Forwarded,
- HttpStatus = 200,
- DurationMs = 42,
- ErrorMessage = "transient timeout",
- ErrorDetail = "stack-trace",
- RequestSummary = "GET /weather",
- ResponseSummary = "{ \"ok\": true }",
- PayloadTruncated = true,
- Extra = "{ \"retryCount\": 1 }",
- ForwardState = AuditForwardState.Pending
- };
+ var original = ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCallCached,
+ status: AuditStatus.Forwarded,
+ eventId: eventId,
+ occurredAtUtc: occurredAt,
+ actor: "design-key",
+ target: "weather-api",
+ sourceNode: "node-a",
+ correlationId: correlationId,
+ executionId: executionId,
+ parentExecutionId: parentExecutionId,
+ sourceSiteId: "site-1",
+ sourceInstanceId: "Pump01",
+ sourceScript: "OnDemand",
+ httpStatus: 200,
+ durationMs: 42,
+ errorMessage: "transient timeout",
+ errorDetail: "stack-trace",
+ requestSummary: "GET /weather",
+ responseSummary: "{ \"ok\": true }",
+ payloadTruncated: true,
+ extra: "{ \"retryCount\": 1 }",
+ ingestedAtUtc: ingestedAt);
var dto = AuditEventDtoMapper.ToDto(original);
var roundTripped = AuditEventDtoMapper.FromDto(dto);
- Assert.Equal(original.EventId, roundTripped.EventId);
- Assert.Equal(original.OccurredAtUtc, roundTripped.OccurredAtUtc);
- Assert.Equal(original.Channel, roundTripped.Channel);
- Assert.Equal(original.Kind, roundTripped.Kind);
- Assert.Equal(original.CorrelationId, roundTripped.CorrelationId);
- Assert.Equal(original.ExecutionId, roundTripped.ExecutionId);
- Assert.Equal(original.ParentExecutionId, roundTripped.ParentExecutionId);
- Assert.Equal(original.SourceSiteId, roundTripped.SourceSiteId);
- Assert.Equal(original.SourceNode, roundTripped.SourceNode);
- Assert.Equal(original.SourceInstanceId, roundTripped.SourceInstanceId);
- Assert.Equal(original.SourceScript, roundTripped.SourceScript);
- Assert.Equal(original.Actor, roundTripped.Actor);
- Assert.Equal(original.Target, roundTripped.Target);
- Assert.Equal(original.Status, roundTripped.Status);
- Assert.Equal(original.HttpStatus, roundTripped.HttpStatus);
- Assert.Equal(original.DurationMs, roundTripped.DurationMs);
- Assert.Equal(original.ErrorMessage, roundTripped.ErrorMessage);
- Assert.Equal(original.ErrorDetail, roundTripped.ErrorDetail);
- Assert.Equal(original.RequestSummary, roundTripped.RequestSummary);
- Assert.Equal(original.ResponseSummary, roundTripped.ResponseSummary);
- Assert.Equal(original.PayloadTruncated, roundTripped.PayloadTruncated);
- Assert.Equal(original.Extra, roundTripped.Extra);
+ var o = original.AsRow();
+ var rt = roundTripped.AsRow();
- // ForwardState + IngestedAtUtc are NOT carried in the proto contract.
- Assert.Null(roundTripped.ForwardState);
- Assert.Null(roundTripped.IngestedAtUtc);
+ Assert.Equal(o.EventId, rt.EventId);
+ Assert.Equal(o.OccurredAtUtc, rt.OccurredAtUtc);
+ Assert.Equal(o.Channel, rt.Channel);
+ Assert.Equal(o.Kind, rt.Kind);
+ Assert.Equal(o.CorrelationId, rt.CorrelationId);
+ Assert.Equal(o.ExecutionId, rt.ExecutionId);
+ Assert.Equal(o.ParentExecutionId, rt.ParentExecutionId);
+ Assert.Equal(o.SourceSiteId, rt.SourceSiteId);
+ Assert.Equal(o.SourceNode, rt.SourceNode);
+ Assert.Equal(o.SourceInstanceId, rt.SourceInstanceId);
+ Assert.Equal(o.SourceScript, rt.SourceScript);
+ Assert.Equal(o.Actor, rt.Actor);
+ Assert.Equal(o.Target, rt.Target);
+ Assert.Equal(o.Status, rt.Status);
+ Assert.Equal(o.HttpStatus, rt.HttpStatus);
+ Assert.Equal(o.DurationMs, rt.DurationMs);
+ Assert.Equal(o.ErrorMessage, rt.ErrorMessage);
+ Assert.Equal(o.ErrorDetail, rt.ErrorDetail);
+ Assert.Equal(o.RequestSummary, rt.RequestSummary);
+ Assert.Equal(o.ResponseSummary, rt.ResponseSummary);
+ Assert.Equal(o.PayloadTruncated, rt.PayloadTruncated);
+ Assert.Equal(o.Extra, rt.Extra);
+
+ // ForwardState is site-storage-only (never on the wire); IngestedAtUtc is
+ // central-set at ingest, so the mapper leaves it null on the wire.
+ Assert.Null(rt.IngestedAtUtc);
}
[Fact]
public void ToDto_NullableStringFields_BecomeEmptyString()
{
- var evt = new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.Notification,
- Kind = AuditKind.NotifySend,
- Status = AuditStatus.Submitted
- // all string? fields left null; CorrelationId null
- };
+ var evt = ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.Notification,
+ kind: AuditKind.NotifySend,
+ status: AuditStatus.Submitted);
+ // all string? fields left null; CorrelationId null
var dto = AuditEventDtoMapper.ToDto(evt);
@@ -139,7 +141,7 @@ public class AuditEventDtoMapperTests
Extra = string.Empty
};
- var evt = AuditEventDtoMapper.FromDto(dto);
+ var evt = AuditEventDtoMapper.FromDto(dto).AsRow();
Assert.Null(evt.CorrelationId);
Assert.Null(evt.ExecutionId);
@@ -161,17 +163,14 @@ public class AuditEventDtoMapperTests
public void ToDto_OccurredAtUtc_PreservesUtcKind()
{
var occurredAt = new DateTime(2026, 5, 20, 8, 0, 0, DateTimeKind.Utc);
- var evt = new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = occurredAt,
- Channel = AuditChannel.DbOutbound,
- Kind = AuditKind.DbWrite,
- Status = AuditStatus.Delivered
- };
+ var evt = ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.DbOutbound,
+ kind: AuditKind.DbWrite,
+ status: AuditStatus.Delivered,
+ occurredAtUtc: occurredAt);
var dto = AuditEventDtoMapper.ToDto(evt);
- var roundTripped = AuditEventDtoMapper.FromDto(dto);
+ var roundTripped = AuditEventDtoMapper.FromDto(dto).AsRow();
Assert.Equal(DateTimeKind.Utc, roundTripped.OccurredAtUtc.Kind);
Assert.Equal(occurredAt, roundTripped.OccurredAtUtc);
@@ -180,16 +179,12 @@ public class AuditEventDtoMapperTests
[Fact]
public void ToDto_NullableInt_BecomesNullInt32Value()
{
- var evt = new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.Notification,
- Kind = AuditKind.NotifySend,
- Status = AuditStatus.Submitted,
- HttpStatus = null,
- DurationMs = null
- };
+ var evt = ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.Notification,
+ kind: AuditKind.NotifySend,
+ status: AuditStatus.Submitted,
+ httpStatus: null,
+ durationMs: null);
var dto = AuditEventDtoMapper.ToDto(evt);
@@ -213,7 +208,7 @@ public class AuditEventDtoMapperTests
Assert.Null(dto.HttpStatus);
Assert.Null(dto.DurationMs);
- var evt = AuditEventDtoMapper.FromDto(dto);
+ var evt = AuditEventDtoMapper.FromDto(dto).AsRow();
Assert.Null(evt.HttpStatus);
Assert.Null(evt.DurationMs);
@@ -222,14 +217,10 @@ public class AuditEventDtoMapperTests
[Fact]
public void ToDto_EnumValues_StoredAsStringNames()
{
- var evt = new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCallCached,
- Status = AuditStatus.Parked
- };
+ var evt = ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCallCached,
+ status: AuditStatus.Parked);
var dto = AuditEventDtoMapper.ToDto(evt);
@@ -241,15 +232,11 @@ public class AuditEventDtoMapperTests
[Fact]
public void AuditEventDto_round_trip_preserves_SourceNode()
{
- var evt = new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- SourceNode = "node-a"
- };
+ var evt = ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ sourceNode: "node-a");
var dto = AuditEventDtoMapper.ToDto(evt);
@@ -265,15 +252,11 @@ public class AuditEventDtoMapperTests
[Fact]
public void AuditEventDto_round_trip_preserves_null_SourceNode()
{
- var evt = new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- SourceNode = null
- };
+ var evt = ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ sourceNode: null);
var dto = AuditEventDtoMapper.ToDto(evt);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorAuditTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorAuditTests.cs
index 3767bea0..97a24dfc 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorAuditTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorAuditTests.cs
@@ -3,10 +3,12 @@ using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
+using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
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.Communication.Actors;
@@ -39,14 +41,12 @@ public class CentralCommunicationActorAuditTests : TestKit
new CentralCommunicationActor(sp, mockFactory, auditIngestAskTimeout)));
}
- private static AuditEvent SampleAuditEvent() => new()
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- };
+ // C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory.
+ private static AuditEvent SampleAuditEvent() =>
+ ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered);
private static SiteCall SampleSiteCall() => new()
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamPullAuditEventsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamPullAuditEventsTests.cs
index 6626c5de..44e1b03b 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamPullAuditEventsTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamPullAuditEventsTests.cs
@@ -4,8 +4,9 @@ using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
-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.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
@@ -31,18 +32,16 @@ public class SiteStreamPullAuditEventsTests : TestKit
return context;
}
- private static AuditEvent NewEvent(DateTime? occurredAt = null) => new()
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = occurredAt
- ?? DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc),
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- SourceSiteId = "site-1",
- PayloadTruncated = false,
- ForwardState = AuditForwardState.Pending,
- };
+ // C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory.
+ // ForwardState is no longer a record field — it is a site-storage-only concern.
+ private static AuditEvent NewEvent(DateTime? occurredAt = null) =>
+ ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ occurredAtUtc: occurredAt
+ ?? DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc),
+ sourceSiteId: "site-1");
[Fact]
public async Task PullAuditEvents_NoQueueWired_ReturnsEmptyResponse()
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs
index 599da48a..7d00b49a 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs
@@ -1,14 +1,14 @@
using Microsoft.EntityFrameworkCore;
-using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
+using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Configurations;
///
/// Schema-level tests for (#23 M1 Bundle B).
-/// Verifies that maps to the AuditLog table with the
+/// Verifies that maps to the AuditLog table with the
/// PK, property set, column types/lengths, and five named indexes specified in alog.md §4.
/// Inspects EF model metadata via the existing in-memory SQLite test context — no
/// database round-trips required.
@@ -34,7 +34,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
// Composite PK {EventId, OccurredAtUtc} is required by the partitioned
// AuditLog table — the clustered key must include the partition column
// (OccurredAtUtc) so each row can be located in its partition (#23 Bundle C).
- var entity = _context.Model.FindEntityType(typeof(AuditEvent));
+ var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
Assert.Equal("AuditLog", entity!.GetTableName());
@@ -43,7 +43,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
Assert.NotNull(pk);
var pkPropertyNames = pk!.Properties.Select(p => p.Name).ToArray();
- Assert.Equal(new[] { nameof(AuditEvent.EventId), nameof(AuditEvent.OccurredAtUtc) }, pkPropertyNames);
+ Assert.Equal(new[] { nameof(AuditLogRow.EventId), nameof(AuditLogRow.OccurredAtUtc) }, pkPropertyNames);
}
[Fact]
@@ -52,7 +52,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
// EventId remains globally unique (the idempotency key for
// InsertIfNotExistsAsync, per M1-T8) via a dedicated unique index that
// is independent of the composite PK.
- var entity = _context.Model.FindEntityType(typeof(AuditEvent));
+ var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
var eventIdIndex = entity!.GetIndexes()
@@ -62,20 +62,20 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
Assert.True(eventIdIndex!.IsUnique);
var indexedProperty = Assert.Single(eventIdIndex.Properties);
- Assert.Equal(nameof(AuditEvent.EventId), indexedProperty.Name);
+ Assert.Equal(nameof(AuditLogRow.EventId), indexedProperty.Name);
}
[Fact]
public void Configure_HasExpectedPropertyCount()
{
- var entity = _context.Model.FindEntityType(typeof(AuditEvent));
+ var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
var properties = entity!.GetProperties()
.Where(p => !p.IsShadowProperty())
.ToList();
- // AuditEvent record exposes 24 init-only properties (alog.md §4 plus the
+ // AuditLogRow record exposes 24 init-only properties (alog.md §4 plus the
// additive ExecutionId universal correlation column, its ParentExecutionId
// sibling, and the SourceNode-stamping column).
Assert.Equal(24, properties.Count);
@@ -84,7 +84,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
[Fact]
public void Configure_ExpectedIndexes_WithCorrectNames()
{
- var entity = _context.Model.FindEntityType(typeof(AuditEvent));
+ var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
var indexNames = entity!.GetIndexes()
@@ -115,13 +115,13 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
}
[Theory]
- [InlineData(nameof(AuditEvent.Channel))]
- [InlineData(nameof(AuditEvent.Kind))]
- [InlineData(nameof(AuditEvent.Status))]
- [InlineData(nameof(AuditEvent.ForwardState))]
+ [InlineData(nameof(AuditLogRow.Channel))]
+ [InlineData(nameof(AuditLogRow.Kind))]
+ [InlineData(nameof(AuditLogRow.Status))]
+ [InlineData(nameof(AuditLogRow.ForwardState))]
public void Configure_EnumColumns_StoredAsVarchar32(string propertyName)
{
- var entity = _context.Model.FindEntityType(typeof(AuditEvent));
+ var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
var property = entity!.FindProperty(propertyName);
@@ -136,7 +136,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
[Fact]
public async Task Configure_UtcConverter_HydratesOccurredAtUtcAsKindUtc()
{
- // Insert an AuditEvent with an Unspecified-Kind DateTime, then re-read
+ // Insert an AuditLogRow with an Unspecified-Kind DateTime, then re-read
// it in a fresh context. The UtcConverter on the OccurredAtUtc /
// IngestedAtUtc columns must re-tag the round-tripped value as
// DateTimeKind.Utc. Without the converter the SQLite (and on production
@@ -147,10 +147,10 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
var eventId = Guid.NewGuid();
var siteId = "test-" + Guid.NewGuid().ToString("N").Substring(0, 8);
- var evt = new AuditEvent
+ var evt = new AuditLogRow
{
EventId = eventId,
- // The AuditEvent record's init-setter (Commons-019 resolution)
+ // The AuditLogRow record's init-setter (Commons-019 resolution)
// re-tags Unspecified values as Utc on assignment, so the value EF
// ultimately writes already has Kind=Utc. The converter's job is
// to keep the Kind tag on the READ path, which the assertions
@@ -163,14 +163,14 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
SourceSiteId = siteId,
};
- _context.Set().Add(evt);
+ _context.Set().Add(evt);
await _context.SaveChangesAsync();
// Detach the tracked entity and re-read in a fresh query so we exercise
// the actual hydrate path, not the change-tracker cache.
_context.ChangeTracker.Clear();
- var loaded = await _context.Set()
+ var loaded = await _context.Set()
.AsNoTracking()
.Where(e => e.SourceSiteId == siteId)
.SingleAsync();
@@ -192,14 +192,14 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
// future config refactor accidentally removing the HasConversion calls.
// The converter type itself is internal to the configuration, so we
// just assert SOME converter is present on each *Utc DateTime column.
- var entity = _context.Model.FindEntityType(typeof(AuditEvent));
+ var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
- var occurredAt = entity!.FindProperty(nameof(AuditEvent.OccurredAtUtc));
+ var occurredAt = entity!.FindProperty(nameof(AuditLogRow.OccurredAtUtc));
Assert.NotNull(occurredAt);
Assert.NotNull(occurredAt!.GetValueConverter());
- var ingestedAt = entity.FindProperty(nameof(AuditEvent.IngestedAtUtc));
+ var ingestedAt = entity.FindProperty(nameof(AuditLogRow.IngestedAtUtc));
Assert.NotNull(ingestedAt);
Assert.NotNull(ingestedAt!.GetValueConverter());
}
@@ -207,7 +207,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
[Fact]
public void Configure_FilteredIndexes_HaveExpectedFilters()
{
- var entity = _context.Model.FindEntityType(typeof(AuditEvent));
+ var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
var correlationIdx = entity!.GetIndexes()
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs
index 1ae62b32..885e740c 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs
@@ -1,9 +1,10 @@
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
-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.ConfigurationDatabase;
+using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
using Xunit;
@@ -42,7 +43,7 @@ public class AuditLogRepositoryTests : IClassFixture
// Re-read in a fresh context so we exercise the persisted row, not the
// (already-bypassed) change tracker.
await using var readContext = CreateContext();
- var loaded = await readContext.Set()
+ var loaded = await readContext.Set()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
@@ -66,7 +67,7 @@ public class AuditLogRepositoryTests : IClassFixture
await repo.InsertIfNotExistsAsync(evt);
await using var readContext = CreateContext();
- var loaded = await readContext.Set()
+ var loaded = await readContext.Set()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
@@ -92,7 +93,7 @@ public class AuditLogRepositoryTests : IClassFixture
await repo.InsertIfNotExistsAsync(evt);
await using var readContext = CreateContext();
- var loaded = await readContext.Set()
+ var loaded = await readContext.Set()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
@@ -114,11 +115,20 @@ public class AuditLogRepositoryTests : IClassFixture
await repo.InsertIfNotExistsAsync(first);
// Same EventId, different payload — first-write-wins, the second call is silently a no-op.
- var second = first with { ErrorMessage = "second-should-be-ignored" };
+ // C3 (Task 2.5): ErrorMessage rides in DetailsJson on the canonical record, so rebuild
+ // a sibling row carrying the same EventId via the factory (rather than a top-level `with`).
+ var second = ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ eventId: first.EventId,
+ occurredAtUtc: occurredAt,
+ sourceSiteId: siteId,
+ errorMessage: "second-should-be-ignored");
await repo.InsertIfNotExistsAsync(second);
await using var readContext = CreateContext();
- var loaded = await readContext.Set()
+ var loaded = await readContext.Set()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
@@ -170,7 +180,7 @@ public class AuditLogRepositoryTests : IClassFixture
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
- Assert.All(rows, r => Assert.Equal(AuditChannel.Notification, r.Channel));
+ Assert.All(rows, r => Assert.Equal(AuditChannel.Notification, r.AsRow().Channel));
}
[SkippableFact]
@@ -196,8 +206,8 @@ public class AuditLogRepositoryTests : IClassFixture
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
- Assert.All(rows, r => Assert.Contains(r.Channel, new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }));
- Assert.DoesNotContain(rows, r => r.Channel == AuditChannel.DbOutbound);
+ Assert.All(rows, r => Assert.Contains(r.AsRow().Channel, new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }));
+ Assert.DoesNotContain(rows, r => r.AsRow().Channel == AuditChannel.DbOutbound);
}
[SkippableFact]
@@ -222,8 +232,8 @@ public class AuditLogRepositoryTests : IClassFixture
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
- Assert.All(rows, r => Assert.Contains(r.Status, new[] { AuditStatus.Failed, AuditStatus.Parked }));
- Assert.DoesNotContain(rows, r => r.Status == AuditStatus.Delivered);
+ Assert.All(rows, r => Assert.Contains(r.AsRow().Status, new[] { AuditStatus.Failed, AuditStatus.Parked }));
+ Assert.DoesNotContain(rows, r => r.AsRow().Status == AuditStatus.Delivered);
}
[SkippableFact]
@@ -247,8 +257,8 @@ public class AuditLogRepositoryTests : IClassFixture
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
- Assert.All(rows, r => Assert.Contains(r.SourceSiteId, new[] { siteA, siteB }));
- Assert.DoesNotContain(rows, r => r.SourceSiteId == siteC);
+ Assert.All(rows, r => Assert.Contains(r.AsRow().SourceSiteId, new[] { siteA, siteB }));
+ Assert.DoesNotContain(rows, r => r.AsRow().SourceSiteId == siteC);
}
[SkippableFact]
@@ -294,7 +304,7 @@ public class AuditLogRepositoryTests : IClassFixture
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
- Assert.All(rows, r => Assert.Equal(siteId, r.SourceSiteId));
+ Assert.All(rows, r => Assert.Equal(siteId, r.AsRow().SourceSiteId));
}
// ──────────────────────────────────────────────────────────────────────
@@ -407,7 +417,7 @@ public class AuditLogRepositoryTests : IClassFixture
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
- Assert.All(rows, r => Assert.Equal(executionId, r.ExecutionId));
+ Assert.All(rows, r => Assert.Equal(executionId, r.AsRow().ExecutionId));
}
[SkippableFact]
@@ -436,7 +446,7 @@ public class AuditLogRepositoryTests : IClassFixture
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
- Assert.All(rows, r => Assert.Equal(parentExecutionId, r.ParentExecutionId));
+ Assert.All(rows, r => Assert.Equal(parentExecutionId, r.AsRow().ParentExecutionId));
}
[SkippableFact]
@@ -494,7 +504,7 @@ public class AuditLogRepositoryTests : IClassFixture
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(
PageSize: 2,
- AfterOccurredAtUtc: cursor.OccurredAtUtc,
+ AfterOccurredAtUtc: cursor.AsRow().OccurredAtUtc,
AfterEventId: cursor.EventId));
Assert.Equal(2, page2.Count);
@@ -506,7 +516,7 @@ public class AuditLogRepositoryTests : IClassFixture
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(
PageSize: 2,
- AfterOccurredAtUtc: cursor2.OccurredAtUtc,
+ AfterOccurredAtUtc: cursor2.AsRow().OccurredAtUtc,
AfterEventId: cursor2.EventId));
Assert.Single(page3);
@@ -541,7 +551,7 @@ public class AuditLogRepositoryTests : IClassFixture
});
await using var readContext = CreateContext();
- var count = await readContext.Set()
+ var count = await readContext.Set()
.Where(e => e.SourceSiteId == siteId)
.CountAsync();
@@ -587,7 +597,7 @@ public class AuditLogRepositoryTests : IClassFixture
filter,
new AuditLogPaging(
PageSize: 2,
- AfterOccurredAtUtc: cursor.OccurredAtUtc,
+ AfterOccurredAtUtc: cursor.AsRow().OccurredAtUtc,
AfterEventId: cursor.EventId));
Assert.Equal(2, page2.Count);
@@ -647,7 +657,7 @@ public class AuditLogRepositoryTests : IClassFixture
await repo.SwitchOutPartitionAsync(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
await using var readContext = CreateContext();
- var remaining = await readContext.Set()
+ var remaining = await readContext.Set()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
@@ -697,11 +707,20 @@ public class AuditLogRepositoryTests : IClassFixture
// (UX_AuditLog_EventId is the index that enables idempotency; if the
// rebuild left it broken, this insert would silently produce a duplicate
// row and the count assertion below would catch it).
- var dup = preExisting with { ErrorMessage = "second-should-be-ignored-after-switch" };
+ // C3 (Task 2.5): rebuild a sibling row with the same EventId via the factory
+ // (ErrorMessage rides in DetailsJson, so a top-level `with` no longer applies).
+ var dup = ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ eventId: preExisting.EventId,
+ occurredAtUtc: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc),
+ sourceSiteId: siteId,
+ errorMessage: "second-should-be-ignored-after-switch");
await repo.InsertIfNotExistsAsync(dup);
await using var readContext = CreateContext();
- var rows = await readContext.Set()
+ var rows = await readContext.Set()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
@@ -1089,6 +1108,9 @@ public class AuditLogRepositoryTests : IClassFixture
private static string NewSiteId() =>
"test-bundle-d-" + Guid.NewGuid().ToString("N").Substring(0, 8);
+ // C3 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared
+ // factory; the repository's transitional shim decomposes it into the 24-column
+ // AuditLogRow on INSERT and recomposes the canonical record on QUERY.
private static AuditEvent NewEvent(
string siteId,
DateTime occurredAtUtc,
@@ -1099,17 +1121,14 @@ public class AuditLogRepositoryTests : IClassFixture
Guid? executionId = null,
Guid? parentExecutionId = null,
string? sourceNode = null) =>
- new()
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = occurredAtUtc,
- Channel = channel,
- Kind = kind,
- Status = status,
- SourceSiteId = siteId,
- SourceNode = sourceNode,
- ErrorMessage = errorMessage,
- ExecutionId = executionId,
- ParentExecutionId = parentExecutionId,
- };
+ ScadaBridgeAuditEventFactory.Create(
+ channel: channel,
+ kind: kind,
+ status: status,
+ occurredAtUtc: occurredAtUtc,
+ sourceNode: sourceNode,
+ sourceSiteId: siteId,
+ executionId: executionId,
+ parentExecutionId: parentExecutionId,
+ errorMessage: errorMessage);
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs
index 96cad893..10577cd4 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs
@@ -5,9 +5,10 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
+using ZB.MOM.WW.Audit;
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.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
@@ -30,14 +31,16 @@ public class AuditWriteMiddlewareTests
///
private sealed class RecordingAuditWriter : ICentralAuditWriter
{
- public List Events { get; } = new();
+ // C3 (Task 2.5): store the decomposed row view so assertions keep
+ // reading the ScadaBridge domain fields as typed properties.
+ public List Events { get; } = new();
public Func? OnWrite { get; set; }
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
lock (Events)
{
- Events.Add(evt);
+ Events.Add(evt.AsRow());
}
return OnWrite?.Invoke(evt) ?? Task.CompletedTask;
@@ -130,8 +133,8 @@ public class AuditWriteMiddlewareTests
Assert.Equal(AuditKind.InboundRequest, evt.Kind);
Assert.Equal(AuditStatus.Delivered, evt.Status);
Assert.Equal(200, evt.HttpStatus);
- // Central direct-write — no ForwardState (alog.md §6).
- Assert.Null(evt.ForwardState);
+ // C3: ForwardState is no longer a canonical field (site-storage-only);
+ // central direct-write rows never carry it.
Assert.NotEqual(Guid.Empty, evt.EventId);
Assert.Equal("echo", evt.Target);
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/MiddlewareOrderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/MiddlewareOrderTests.cs
index 151d44a1..b47b836e 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/MiddlewareOrderTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/MiddlewareOrderTests.cs
@@ -7,9 +7,10 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
+using ZB.MOM.WW.Audit;
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.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
using System.Security.Claims;
@@ -50,10 +51,12 @@ public class MiddlewareOrderTests
private sealed class RecordingAuditWriter : ICentralAuditWriter
{
- public List Events { get; } = new();
+ // C3 (Task 2.5): store the decomposed row view so assertions keep
+ // reading the ScadaBridge domain fields as typed properties.
+ public List Events { get; } = new();
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
- lock (Events) { Events.Add(evt); }
+ lock (Events) { Events.Add(evt.AsRow()); }
return Task.CompletedTask;
}
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs
index 5f53a4c8..09b6a1f1 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs
@@ -9,7 +9,7 @@ using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
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.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
@@ -105,18 +105,18 @@ public class SiteAuditPushFlowTests : TestKit
=> throw new NotSupportedException();
}
- private static AuditEvent NewPendingEvent(Guid id) => new()
- {
- EventId = id,
- OccurredAtUtc = new DateTime(2026, 5, 21, 9, 0, 0, DateTimeKind.Utc),
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- SourceSiteId = "site-1",
- Target = "ext-system-1",
- PayloadTruncated = false,
- ForwardState = AuditForwardState.Pending,
- };
+ // C3 (Task 2.5): canonical record via the shared factory; ForwardState is a
+ // site-storage-only concern (defaulted to Pending by the SQLite writer), not a
+ // field on the canonical record.
+ private static AuditEvent NewPendingEvent(Guid id) =>
+ ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ eventId: id,
+ occurredAtUtc: new DateTime(2026, 5, 21, 9, 0, 0, DateTimeKind.Utc),
+ target: "ext-system-1",
+ sourceSiteId: "site-1");
[Fact]
public async Task SiteAuditEvent_DrainsToCentral_AndFlipsSiteRowToForwarded()
@@ -197,14 +197,16 @@ public class SiteAuditPushFlowTests : TestKit
// The site row reached AuditForwardState.Forwarded specifically —
// not merely "no longer Pending" (a Reconciled row would also leave
// ReadPendingAsync, so we assert the positive Forwarded state).
+ // C3 (Task 2.5): ForwardState is a site-storage-only column, no longer a
+ // field on the canonical record. ReadForwardedAsync only returns rows in
+ // the Forwarded state, so a single match here proves the row reached it.
var forwarded = await writer.ReadForwardedAsync(256, CancellationToken.None);
- var row = Assert.Single(forwarded, r => r.EventId == eventId);
- Assert.Equal(AuditForwardState.Forwarded, row.ForwardState);
+ Assert.Single(forwarded, r => r.EventId == eventId);
}, TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(250));
// The central-persisted row carries the central-stamped IngestedAtUtc.
var ingested = centralRepo.Rows.Single(r => r.EventId == eventId);
- Assert.NotNull(ingested.IngestedAtUtc);
+ Assert.NotNull(ingested.AsRow().IngestedAtUtc);
// Cleanup the temp SQLite file.
try { File.Delete(dbPath); } catch { /* best-effort */ }
diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/NotificationOutboxFlowTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/NotificationOutboxFlowTests.cs
index deded2e6..675f156a 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/NotificationOutboxFlowTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/NotificationOutboxFlowTests.cs
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
-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.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditEndpointsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditEndpointsTests.cs
index ccce0dee..0013617a 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditEndpointsTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditEndpointsTests.cs
@@ -10,7 +10,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
-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.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -35,16 +35,18 @@ public class AuditEndpointsTests
{
private const string BasicCredential = "auditor:password";
- private static AuditEvent SampleEvent(Guid? id = null, DateTime? occurredAt = null) => new()
- {
- EventId = id ?? Guid.Parse("11111111-1111-1111-1111-111111111111"),
- OccurredAtUtc = occurredAt ?? new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- SourceSiteId = "plant-a",
- Status = AuditStatus.Delivered,
- HttpStatus = 200,
- };
+ // C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory;
+ // the endpoint serializes the canonical record (eventId + occurredAtUtc top-level,
+ // domain fields out of DetailsJson) so these tests assert on the JSON/CSV output.
+ private static AuditEvent SampleEvent(Guid? id = null, DateTime? occurredAt = null) =>
+ ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ eventId: id ?? Guid.Parse("11111111-1111-1111-1111-111111111111"),
+ occurredAtUtc: occurredAt ?? new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
+ sourceSiteId: "plant-a",
+ httpStatus: 200);
///
/// Builds an in-process TestServer hosting the audit endpoints with stubbed
diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs
index 75e3ee9b..ccef7308 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs
@@ -3,10 +3,11 @@ using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
-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.Interfaces.Repositories;
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.NotificationOutbox.Delivery;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages;
@@ -38,14 +39,17 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
///
private sealed class RecordingCentralAuditWriter : ICentralAuditWriter
{
- public List Events { get; } = new();
+ // C3 (Task 2.5): store the decomposed row view so assertions keep
+ // reading the ScadaBridge domain fields (Channel/Kind/Status/…) as
+ // typed properties; the canonical record carries them in DetailsJson.
+ public List Events { get; } = new();
public Func? OnWrite { get; set; }
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
lock (Events)
{
- Events.Add(evt);
+ Events.Add(evt.AsRow());
}
return OnWrite?.Invoke(evt) ?? Task.CompletedTask;
@@ -126,7 +130,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
.Returns(new[] { config });
}
- private List EventsByStatus(AuditStatus status)
+ private List EventsByStatus(AuditStatus status)
{
lock (_auditWriter.Events)
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorAuditInjectionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorAuditInjectionTests.cs
index c6885b88..dce94903 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorAuditInjectionTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorAuditInjectionTests.cs
@@ -3,7 +3,7 @@ using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
-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.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs
index 3355e0b4..c8afb4a8 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs
@@ -3,11 +3,12 @@ using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
-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.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
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.NotificationOutbox.Delivery;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages;
@@ -35,14 +36,16 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
private sealed class RecordingCentralAuditWriter : ICentralAuditWriter
{
- public List Events { get; } = new();
+ // C3 (Task 2.5): store the decomposed row view so assertions keep
+ // reading the ScadaBridge domain fields as typed properties.
+ public List Events { get; } = new();
public Func? OnWrite { get; set; }
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
lock (Events)
{
- Events.Add(evt);
+ Events.Add(evt.AsRow());
}
return OnWrite?.Invoke(evt) ?? Task.CompletedTask;
@@ -118,7 +121,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
.Returns(new[] { config });
}
- private List EventsByStatus(AuditStatus status)
+ private List EventsByStatus(AuditStatus status)
{
lock (_auditWriter.Events)
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/TestSupport/NoOpCentralAuditWriter.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/TestSupport/NoOpCentralAuditWriter.cs
index 6bedd735..debadebb 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/TestSupport/NoOpCentralAuditWriter.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/TestSupport/NoOpCentralAuditWriter.cs
@@ -1,4 +1,4 @@
-using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
+using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.TestSupport;
diff --git a/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/AuditLog/HotPathLatencyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/AuditLog/HotPathLatencyTests.cs
index 44e81dc7..15934cf9 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/AuditLog/HotPathLatencyTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/AuditLog/HotPathLatencyTests.cs
@@ -2,22 +2,24 @@ using System.Diagnostics;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
+using ZB.MOM.WW.Audit;
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.AuditLog.Redaction;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.PerformanceTests.AuditLog;
///
-/// Bundle D (M5-T9) hot-path latency budget for .
-/// The filter sits between event construction and persistence on every audit
+/// Bundle D (M5-T9) hot-path latency budget for .
+/// The redactor sits between event construction and persistence on every audit
/// row — site SQLite hot-path and central direct-write both — so it MUST stay
-/// out of the way of script-thread latency.
+/// out of the way of script-thread latency. (C3, Task 2.5: this replaces the
+/// former IAuditPayloadFilter hot-path component.)
///
///
///
-/// Methodology: warm-up + N iterations, time each
+/// Methodology: warm-up + N iterations, time each
/// with , sort, take p95, assert under threshold. Matches
/// the simple-loop style of the existing StaggeredStartupTests /
/// HealthAggregationTests in this project (no BenchmarkDotNet).
@@ -37,23 +39,20 @@ public class HotPathLatencyTests
private const int WarmupIterations = 200;
private const int MeasureIterations = 2_000;
- private static DefaultAuditPayloadFilter Filter(AuditLogOptions opts) => new(
+ // C3 (Task 2.5): the hot-path redactor is now the canonical
+ // ScadaBridgeAuditRedactor (IAuditRedactor) operating on the canonical record;
+ // it ports the old DefaultAuditPayloadFilter redaction + truncation behaviour.
+ private static ScadaBridgeAuditRedactor Filter(AuditLogOptions opts) => new(
new StaticMonitor(opts),
- NullLogger.Instance);
+ NullLogger.Instance);
- private static AuditEvent NewEvent(string request)
- {
- return new AuditEvent
- {
- EventId = Guid.NewGuid(),
- OccurredAtUtc = DateTime.UtcNow,
- Channel = AuditChannel.ApiOutbound,
- Kind = AuditKind.ApiCall,
- Status = AuditStatus.Delivered,
- Target = "esg.target",
- RequestSummary = request,
- };
- }
+ private static AuditEvent NewEvent(string request) =>
+ ScadaBridgeAuditEventFactory.Create(
+ channel: AuditChannel.ApiOutbound,
+ kind: AuditKind.ApiCall,
+ status: AuditStatus.Delivered,
+ target: "esg.target",
+ requestSummary: request);
///
/// Run N times, returning the p95 in microseconds.
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs
index 37070073..ba62aee5 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs
@@ -1,9 +1,10 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
-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.Messages.Integration;
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.SiteRuntime.Scripts;
@@ -85,16 +86,16 @@ public class DatabaseCachedWriteEmissionTests
Assert.NotEqual(default, trackedId);
var packet = Assert.Single(forwarder.Telemetry);
- Assert.Equal(AuditChannel.DbOutbound, packet.Audit.Channel);
- Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind);
- Assert.Equal(AuditStatus.Submitted, packet.Audit.Status);
- Assert.Equal("myDb", packet.Audit.Target);
+ Assert.Equal(AuditChannel.DbOutbound, packet.Audit.AsRow().Channel);
+ Assert.Equal(AuditKind.CachedSubmit, packet.Audit.AsRow().Kind);
+ Assert.Equal(AuditStatus.Submitted, packet.Audit.AsRow().Status);
+ Assert.Equal("myDb", packet.Audit.AsRow().Target);
// CorrelationId is the per-operation lifecycle id (TrackedOperationId);
// ExecutionId is the per-execution id from the runtime context.
- Assert.Equal(trackedId.Value, packet.Audit.CorrelationId);
- Assert.Equal(TestExecutionId, packet.Audit.ExecutionId);
+ Assert.Equal(trackedId.Value, packet.Audit.AsRow().CorrelationId);
+ Assert.Equal(TestExecutionId, packet.Audit.AsRow().ExecutionId);
// Audit Log #23 (ParentExecutionId): null for a non-routed run.
- Assert.Null(packet.Audit.ParentExecutionId);
+ Assert.Null(packet.Audit.AsRow().ParentExecutionId);
Assert.Equal(trackedId, packet.Operational.TrackedOperationId);
Assert.Equal("DbOutbound", packet.Operational.Channel);
@@ -124,9 +125,9 @@ public class DatabaseCachedWriteEmissionTests
await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
var packet = Assert.Single(forwarder.Telemetry);
- Assert.Equal(SiteId, packet.Audit.SourceSiteId);
- Assert.Equal(InstanceName, packet.Audit.SourceInstanceId);
- Assert.Equal(SourceScript, packet.Audit.SourceScript);
+ Assert.Equal(SiteId, packet.Audit.AsRow().SourceSiteId);
+ Assert.Equal(InstanceName, packet.Audit.AsRow().SourceInstanceId);
+ Assert.Equal(SourceScript, packet.Audit.AsRow().SourceScript);
Assert.Equal(SiteId, packet.Operational.SourceSite);
}
@@ -153,7 +154,7 @@ public class DatabaseCachedWriteEmissionTests
await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
var packet = Assert.Single(forwarder.Telemetry);
- Assert.Equal(parentExecutionId, packet.Audit.ParentExecutionId);
+ Assert.Equal(parentExecutionId, packet.Audit.AsRow().ParentExecutionId);
}
[Fact]
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs
index f123a248..8b17e19c 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs
@@ -1,10 +1,12 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
-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.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
+using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
@@ -28,7 +30,9 @@ public class DatabaseSyncEmissionTests
///
private sealed class CapturingAuditWriter : IAuditWriter
{
- public List Events { get; } = new();
+ // C3 (Task 2.5): store the decomposed row view so assertions keep
+ // reading the ScadaBridge domain fields as typed properties.
+ public List Events { get; } = new();
public Exception? ThrowOnWrite { get; set; }
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
@@ -38,7 +42,7 @@ public class DatabaseSyncEmissionTests
return Task.FromException(ThrowOnWrite);
}
- Events.Add(evt);
+ Events.Add(evt.AsRow());
return Task.CompletedTask;
}
}
@@ -134,7 +138,7 @@ public class DatabaseSyncEmissionTests
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
Assert.Equal(AuditKind.DbWrite, evt.Kind);
Assert.Equal(AuditStatus.Delivered, evt.Status);
- Assert.Equal(AuditForwardState.Pending, evt.ForwardState);
+ // C3: ForwardState is no longer a canonical field (site-storage-only).
Assert.NotNull(evt.Extra);
Assert.Contains("\"op\":\"write\"", evt.Extra);
Assert.Contains("\"rowsAffected\":1", evt.Extra);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs
index 1ff3c8e2..59fc0891 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs
@@ -2,10 +2,12 @@ using Akka.Actor;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
-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.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
+using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
@@ -38,11 +40,13 @@ public class ExecutionCorrelationContextTests
///
private sealed class CapturingAuditWriter : IAuditWriter
{
- public List Events { get; } = new();
+ // C3 (Task 2.5): store the decomposed row view so assertions keep
+ // reading the ScadaBridge domain fields as typed properties.
+ public List Events { get; } = new();
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
- Events.Add(evt);
+ Events.Add(evt.AsRow());
return Task.CompletedTask;
}
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs
index ee35de2b..8ce5c03d 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs
@@ -1,9 +1,10 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
-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.Messages.Integration;
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.SiteRuntime.Scripts;
@@ -89,15 +90,17 @@ public class ExternalSystemCachedCallEmissionTests
Assert.Single(forwarder.Telemetry);
var packet = forwarder.Telemetry[0];
- Assert.Equal(AuditChannel.ApiOutbound, packet.Audit.Channel);
- Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind);
- Assert.Equal(AuditStatus.Submitted, packet.Audit.Status);
- Assert.Equal("ERP.GetOrder", packet.Audit.Target);
+ Assert.Equal(AuditChannel.ApiOutbound, packet.Audit.AsRow().Channel);
+ Assert.Equal(AuditKind.CachedSubmit, packet.Audit.AsRow().Kind);
+ Assert.Equal(AuditStatus.Submitted, packet.Audit.AsRow().Status);
+ Assert.Equal("ERP.GetOrder", packet.Audit.AsRow().Target);
// CorrelationId is the per-operation lifecycle id (TrackedOperationId);
// ExecutionId is the per-execution id from the runtime context.
- Assert.Equal(trackedId.Value, packet.Audit.CorrelationId);
- Assert.Equal(TestExecutionId, packet.Audit.ExecutionId);
- Assert.Equal(AuditForwardState.Pending, packet.Audit.ForwardState);
+ Assert.Equal(trackedId.Value, packet.Audit.AsRow().CorrelationId);
+ Assert.Equal(TestExecutionId, packet.Audit.AsRow().ExecutionId);
+ // C3 (Task 2.5): ForwardState is no longer a field on the canonical
+ // record — it is a site-storage-only concern (the SQLite writer defaults
+ // it to Pending on insert), so the telemetry packet no longer carries it.
// Operational mirror — same id, Submitted, RetryCount 0, not terminal.
Assert.Equal(trackedId, packet.Operational.TrackedOperationId);
@@ -131,20 +134,20 @@ public class ExternalSystemCachedCallEmissionTests
// Immediate completion (WasBuffered=false) emits Submit, Attempted, Resolve.
Assert.Equal(3, forwarder.Telemetry.Count);
- var submit = forwarder.Telemetry.Single(t => t.Audit.Kind == AuditKind.CachedSubmit);
- var attempted = forwarder.Telemetry.Single(t => t.Audit.Kind == AuditKind.ApiCallCached);
- var resolve = forwarder.Telemetry.Single(t => t.Audit.Kind == AuditKind.CachedResolve);
+ var submit = forwarder.Telemetry.Single(t => t.Audit.AsRow().Kind == AuditKind.CachedSubmit);
+ var attempted = forwarder.Telemetry.Single(t => t.Audit.AsRow().Kind == AuditKind.ApiCallCached);
+ var resolve = forwarder.Telemetry.Single(t => t.Audit.AsRow().Kind == AuditKind.CachedResolve);
// Every row carries the request args; the two post-call rows also carry
// the response body (Submit precedes the call, so it has no response).
- Assert.Equal("{\"orderId\":42}", submit.Audit.RequestSummary);
- Assert.Null(submit.Audit.ResponseSummary);
+ Assert.Equal("{\"orderId\":42}", submit.Audit.AsRow().RequestSummary);
+ Assert.Null(submit.Audit.AsRow().ResponseSummary);
- Assert.Equal("{\"orderId\":42}", attempted.Audit.RequestSummary);
- Assert.Equal("{\"ok\":true}", attempted.Audit.ResponseSummary);
+ Assert.Equal("{\"orderId\":42}", attempted.Audit.AsRow().RequestSummary);
+ Assert.Equal("{\"ok\":true}", attempted.Audit.AsRow().ResponseSummary);
- Assert.Equal("{\"orderId\":42}", resolve.Audit.RequestSummary);
- Assert.Equal("{\"ok\":true}", resolve.Audit.ResponseSummary);
+ Assert.Equal("{\"orderId\":42}", resolve.Audit.AsRow().RequestSummary);
+ Assert.Equal("{\"ok\":true}", resolve.Audit.AsRow().ResponseSummary);
}
[Fact]
@@ -356,9 +359,9 @@ public class ExternalSystemCachedCallEmissionTests
await helper.CachedCall("ERP", "GetOrder");
var packet = Assert.Single(forwarder.Telemetry);
- Assert.Equal(SiteId, packet.Audit.SourceSiteId);
- Assert.Equal(InstanceName, packet.Audit.SourceInstanceId);
- Assert.Equal(SourceScript, packet.Audit.SourceScript);
+ Assert.Equal(SiteId, packet.Audit.AsRow().SourceSiteId);
+ Assert.Equal(InstanceName, packet.Audit.AsRow().SourceInstanceId);
+ Assert.Equal(SourceScript, packet.Audit.AsRow().SourceScript);
Assert.Equal(SiteId, packet.Operational.SourceSite);
}
@@ -431,31 +434,31 @@ public class ExternalSystemCachedCallEmissionTests
Assert.Equal(3, forwarder.Telemetry.Count);
var submit = forwarder.Telemetry[0];
- Assert.Equal(AuditKind.CachedSubmit, submit.Audit.Kind);
- Assert.Equal(AuditStatus.Submitted, submit.Audit.Status);
- Assert.Equal(TestExecutionId, submit.Audit.ExecutionId);
+ Assert.Equal(AuditKind.CachedSubmit, submit.Audit.AsRow().Kind);
+ Assert.Equal(AuditStatus.Submitted, submit.Audit.AsRow().Status);
+ Assert.Equal(TestExecutionId, submit.Audit.AsRow().ExecutionId);
Assert.Equal(trackedId, submit.Operational.TrackedOperationId);
Assert.Null(submit.Operational.TerminalAtUtc);
var attempted = forwarder.Telemetry[1];
- Assert.Equal(AuditChannel.ApiOutbound, attempted.Audit.Channel);
- Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.Kind);
- Assert.Equal(AuditStatus.Attempted, attempted.Audit.Status);
+ Assert.Equal(AuditChannel.ApiOutbound, attempted.Audit.AsRow().Channel);
+ Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.AsRow().Kind);
+ Assert.Equal(AuditStatus.Attempted, attempted.Audit.AsRow().Status);
// Cached rows: CorrelationId = TrackedOperationId; ExecutionId is the
// per-execution id from the runtime context.
- Assert.Equal(trackedId.Value, attempted.Audit.CorrelationId);
- Assert.Equal(TestExecutionId, attempted.Audit.ExecutionId);
- Assert.Equal("ERP.GetOrder", attempted.Audit.Target);
+ Assert.Equal(trackedId.Value, attempted.Audit.AsRow().CorrelationId);
+ Assert.Equal(TestExecutionId, attempted.Audit.AsRow().ExecutionId);
+ Assert.Equal("ERP.GetOrder", attempted.Audit.AsRow().Target);
Assert.Equal(trackedId, attempted.Operational.TrackedOperationId);
Assert.Equal("Attempted", attempted.Operational.Status);
Assert.Null(attempted.Operational.TerminalAtUtc);
var resolve = forwarder.Telemetry[2];
- Assert.Equal(AuditChannel.ApiOutbound, resolve.Audit.Channel);
- Assert.Equal(AuditKind.CachedResolve, resolve.Audit.Kind);
- Assert.Equal(AuditStatus.Delivered, resolve.Audit.Status);
- Assert.Equal(trackedId.Value, resolve.Audit.CorrelationId);
- Assert.Equal(TestExecutionId, resolve.Audit.ExecutionId);
+ Assert.Equal(AuditChannel.ApiOutbound, resolve.Audit.AsRow().Channel);
+ Assert.Equal(AuditKind.CachedResolve, resolve.Audit.AsRow().Kind);
+ Assert.Equal(AuditStatus.Delivered, resolve.Audit.AsRow().Status);
+ Assert.Equal(trackedId.Value, resolve.Audit.AsRow().CorrelationId);
+ Assert.Equal(TestExecutionId, resolve.Audit.AsRow().ExecutionId);
Assert.Equal(trackedId, resolve.Operational.TrackedOperationId);
Assert.Equal("Delivered", resolve.Operational.Status);
// Terminal row carries TerminalAtUtc.
@@ -463,9 +466,9 @@ public class ExternalSystemCachedCallEmissionTests
// Audit Log #23 (ParentExecutionId): null on every script-side cached
// row for a non-routed run.
- Assert.Null(submit.Audit.ParentExecutionId);
- Assert.Null(attempted.Audit.ParentExecutionId);
- Assert.Null(resolve.Audit.ParentExecutionId);
+ Assert.Null(submit.Audit.AsRow().ParentExecutionId);
+ Assert.Null(attempted.Audit.AsRow().ParentExecutionId);
+ Assert.Null(resolve.Audit.AsRow().ParentExecutionId);
}
[Fact]
@@ -493,7 +496,7 @@ public class ExternalSystemCachedCallEmissionTests
Assert.Equal(3, forwarder.Telemetry.Count);
Assert.All(forwarder.Telemetry, t =>
- Assert.Equal(parentExecutionId, t.Audit.ParentExecutionId));
+ Assert.Equal(parentExecutionId, t.Audit.AsRow().ParentExecutionId));
}
///
@@ -525,15 +528,15 @@ public class ExternalSystemCachedCallEmissionTests
Assert.Equal(3, forwarder.Telemetry.Count);
var attempted = forwarder.Telemetry[1];
- Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.Kind);
- Assert.Equal(AuditStatus.Attempted, attempted.Audit.Status);
+ Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.AsRow().Kind);
+ Assert.Equal(AuditStatus.Attempted, attempted.Audit.AsRow().Status);
// The per-attempt row carries the error message.
- Assert.NotNull(attempted.Audit.ErrorMessage);
+ Assert.NotNull(attempted.Audit.AsRow().ErrorMessage);
var resolve = forwarder.Telemetry[2];
- Assert.Equal(AuditKind.CachedResolve, resolve.Audit.Kind);
+ Assert.Equal(AuditKind.CachedResolve, resolve.Audit.AsRow().Kind);
// Immediate permanent failure -> Failed audit status / operational Failed.
- Assert.Equal(AuditStatus.Failed, resolve.Audit.Status);
+ Assert.Equal(AuditStatus.Failed, resolve.Audit.AsRow().Status);
Assert.Equal("Failed", resolve.Operational.Status);
Assert.NotNull(resolve.Operational.TerminalAtUtc);
Assert.NotNull(resolve.Operational.LastError);
@@ -568,7 +571,7 @@ public class ExternalSystemCachedCallEmissionTests
// Only the CachedSubmit row — no Attempted / Resolve from the helper.
var only = Assert.Single(forwarder.Telemetry);
- Assert.Equal(AuditKind.CachedSubmit, only.Audit.Kind);
+ Assert.Equal(AuditKind.CachedSubmit, only.Audit.AsRow().Kind);
}
// ── SourceNode-stamping (Task 14) ──
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs
index 7b464eb8..351723e1 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs
@@ -1,10 +1,12 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
-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.Types;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
+using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
@@ -26,7 +28,9 @@ public class ExternalSystemCallAuditEmissionTests
///
private sealed class CapturingAuditWriter : IAuditWriter
{
- public List Events { get; } = new();
+ // C3 (Task 2.5): store the decomposed row view so assertions keep
+ // reading the ScadaBridge domain fields as typed properties.
+ public List Events { get; } = new();
public Exception? ThrowOnWrite { get; set; }
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
@@ -36,7 +40,7 @@ public class ExternalSystemCallAuditEmissionTests
return Task.FromException(ThrowOnWrite);
}
- Events.Add(evt);
+ Events.Add(evt.AsRow());
return Task.CompletedTask;
}
}
@@ -94,7 +98,9 @@ public class ExternalSystemCallAuditEmissionTests
Assert.Equal(AuditKind.ApiCall, evt.Kind);
Assert.Equal(AuditStatus.Delivered, evt.Status);
Assert.Equal("ERP.GetOrder", evt.Target);
- Assert.Equal(AuditForwardState.Pending, evt.ForwardState);
+ // C3 (Task 2.5): ForwardState is no longer a field on the canonical
+ // record — it is a site-storage-only concern. The emitter no longer
+ // sets it; the site SQLite writer defaults it to Pending on insert.
Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind);
Assert.NotEqual(Guid.Empty, evt.EventId);
Assert.False(evt.PayloadTruncated);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs
index a14ceab8..9bfa69c8 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs
@@ -3,10 +3,12 @@ using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Data.Sqlite;
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.Services;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
+using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
@@ -31,7 +33,9 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
///
private sealed class CapturingAuditWriter : IAuditWriter
{
- public List Events { get; } = new();
+ // C3 (Task 2.5): store the decomposed row view so assertions keep
+ // reading the ScadaBridge domain fields as typed properties.
+ public List Events { get; } = new();
public Exception? ThrowOnWrite { get; set; }
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
@@ -41,7 +45,7 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
return Task.FromException(ThrowOnWrite);
}
- Events.Add(evt);
+ Events.Add(evt.AsRow());
return Task.CompletedTask;
}
}
@@ -128,7 +132,7 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
Assert.Equal(AuditChannel.Notification, evt.Channel);
Assert.Equal(AuditKind.NotifySend, evt.Kind);
Assert.Equal(AuditStatus.Submitted, evt.Status);
- Assert.Equal(AuditForwardState.Pending, evt.ForwardState);
+ // C3: ForwardState is no longer a canonical field (site-storage-only).
Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind);
Assert.NotEqual(Guid.Empty, evt.EventId);
Assert.False(evt.PayloadTruncated);