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);