Merge branch 'feature/audit-log-m4-remaining-boundaries': Audit Log #23 M4 Remaining Boundary Emission
M4 closes the script-trust-boundary emission gaps: - Sync DB writes/reads via AuditingDbConnection decorator (Channel=DbOutbound, Kind=DbWrite; Extra carries op + rowsAffected/rowsReturned). - Notification Outbox dispatcher: NotifyDeliver(Attempted) per attempt; NotifyDeliver(Delivered/Parked/Discarded) on terminal. Direct-write via new ICentralAuditWriter (CentralAuditWriter implementation wraps IAuditLogRepository.InsertIfNotExistsAsync with scope-per-call). - Site Notify.To().Send() emits NotifySend(Submitted) via the existing IAuditWriter site path; correlation via NotificationId. - Inbound API AuditWriteMiddleware emits InboundRequest on success, InboundAuthFailure on 401/403; Actor = API key NAME (never material); registered via UseWhen(/api/) AFTER UseAuthentication/UseAuthorization; audit failure NEVER changes HTTP response. Audit-write-failure-never-aborts-action proven end-to-end across all five new code paths via AuditWriteFailureSafetyTests (broken ICentralAuditWriter + broken IAuditWriter scenarios all green). Shipped: 12 commits, ~62 net new tests across SiteRuntime / NotificationOutbox / AuditLog / InboundAPI tests. Full solution 2763 tests passing. No regressions. infra/* untouched on any branch commit.
This commit is contained in:
@@ -29,6 +29,7 @@
|
|||||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="10.0.7" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.7" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="10.0.7" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.3" />
|
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.3" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="5.0.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="5.0.0" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.0.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.0.0" />
|
||||||
|
|||||||
24
docs/plans/2026-05-20-auditlog-m4-remaining-boundaries.md
Normal file
24
docs/plans/2026-05-20-auditlog-m4-remaining-boundaries.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Audit Log #23 — M4 Remaining Boundary Emission Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (bundled cadence).
|
||||||
|
|
||||||
|
**Goal:** Close every remaining script-trust-boundary emission gap: sync DB writes/reads via Database.Connection().Execute*/ExecuteReader, Notification Outbox central dispatcher attempts + terminal, site-side Notify.Send submission, and Inbound API middleware. Audit-write failure NEVER aborts the user-facing action across all five new code paths.
|
||||||
|
|
||||||
|
**Vocabulary (M3 reality-locked):**
|
||||||
|
- `AuditKind.DbWrite` (Channel=DbOutbound) for both Execute and ExecuteReader; `Extra` carries `{"op":"write"|"read","rowsAffected":N|"rowsReturned":N}`.
|
||||||
|
- `AuditKind.NotifyDeliver` for each Notification Outbox attempt; `AuditStatus.Attempted` on attempts, `AuditStatus.Delivered|Failed|Parked|Discarded` on terminal.
|
||||||
|
- `AuditKind.NotifySend` for site-emit at Notify.Send; `AuditStatus.Submitted`.
|
||||||
|
- `AuditKind.InboundRequest` for happy-path inbound; `AuditStatus.Delivered`. `AuditKind.InboundAuthFailure` for 401; `AuditStatus.Failed`.
|
||||||
|
- `AuditStatus.Failed` replaces "PermanentFailure" / "TransientFailure" terminal wording throughout.
|
||||||
|
|
||||||
|
**Bundles:**
|
||||||
|
- Bundle A — DB sync emissions (T1, T2)
|
||||||
|
- Bundle B — NotificationOutbox central emissions (T3, T4, T5)
|
||||||
|
- Bundle C — Site Notify.Send emission (T6)
|
||||||
|
- Bundle D — Inbound API audit middleware (T7, T8)
|
||||||
|
- Bundle E — Integration tests (T9, T10, T11, T12)
|
||||||
|
- Final cross-bundle review + merge
|
||||||
|
|
||||||
|
Each task follows the M2 Bundle F / M3 Bundle E emission pattern: capture timing, build AuditEvent with provenance, write via try/catch that swallows + logs, never propagate audit failure to the user-facing action. Mirror M2's ScriptRuntimeContext wrapper pattern where the emission is script-context-aware.
|
||||||
|
|
||||||
|
Integration tests go in `tests/ScadaLink.AuditLog.Tests/Integration/` (component-level per M2 Bundle H + M3 Bundle G — the existing IntegrationTests factory disables Akka).
|
||||||
82
src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs
Normal file
82
src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
|
||||||
|
namespace ScadaLink.AuditLog.Central;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central-only direct-write implementation of <see cref="ICentralAuditWriter"/>.
|
||||||
|
/// Wraps <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> as a best-effort
|
||||||
|
/// audit emission path for components that originate audit events ON the central
|
||||||
|
/// node (Notification Outbox dispatch, Inbound API) — NOT for site telemetry
|
||||||
|
/// ingest (that path is the SiteAudit → AuditLogIngestActor batched flow).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Best-effort contract.</b> Audit-write failures NEVER abort the user-facing
|
||||||
|
/// action (alog.md §13). The writer catches every exception thrown by repository
|
||||||
|
/// resolution or the insert call, logs at warning, and returns successfully.
|
||||||
|
/// Callers may still wrap the call in their own try/catch (defensive — the writer
|
||||||
|
/// is supposed to swallow).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Scope-per-call resolution.</b> <see cref="IAuditLogRepository"/> is a SCOPED
|
||||||
|
/// EF Core service (registered by <c>ScadaLink.ConfigurationDatabase</c>). The
|
||||||
|
/// writer itself is registered as a singleton (so all callers share one instance),
|
||||||
|
/// so it cannot hold a scope across calls — it opens a fresh
|
||||||
|
/// <see cref="IServiceScope"/> per <see cref="WriteAsync"/> invocation, mirroring
|
||||||
|
/// the per-message scope pattern used by <c>AuditLogIngestActor</c> and
|
||||||
|
/// <c>NotificationOutboxActor</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Idempotency.</b> Persistence is via <c>InsertIfNotExistsAsync</c>, so a
|
||||||
|
/// double-emitted event (same <see cref="AuditEvent.EventId"/>) is a silent
|
||||||
|
/// no-op — the writer is safe to call from any number of dispatch paths.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class CentralAuditWriter : ICentralAuditWriter
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<CentralAuditWriter> _logger;
|
||||||
|
|
||||||
|
public CentralAuditWriter(IServiceProvider services, ILogger<CentralAuditWriter> logger)
|
||||||
|
{
|
||||||
|
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists <paramref name="evt"/> into the central <c>AuditLog</c> table
|
||||||
|
/// idempotently on <see cref="AuditEvent.EventId"/>. Stamps
|
||||||
|
/// <see cref="AuditEvent.IngestedAtUtc"/> from the central-side clock.
|
||||||
|
/// Internal failures are logged and swallowed — never thrown.
|
||||||
|
/// </summary>
|
||||||
|
public async Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (evt is null)
|
||||||
|
{
|
||||||
|
// Defensive — a null event is a programming bug at the caller and
|
||||||
|
// produces no meaningful audit row. Log and return.
|
||||||
|
_logger.LogWarning("CentralAuditWriter.WriteAsync received null event; ignoring.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = _services.CreateAsyncScope();
|
||||||
|
var repo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||||
|
var stamped = evt with { IngestedAtUtc = DateTime.UtcNow };
|
||||||
|
await repo.InsertIfNotExistsAsync(stamped, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Audit failure NEVER aborts the user-facing action — swallow and log.
|
||||||
|
_logger.LogWarning(
|
||||||
|
ex,
|
||||||
|
"CentralAuditWriter failed for EventId {EventId} (Kind={Kind}, Status={Status})",
|
||||||
|
evt.EventId, evt.Kind, evt.Status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ScadaLink.AuditLog.Central;
|
||||||
using ScadaLink.AuditLog.Configuration;
|
using ScadaLink.AuditLog.Configuration;
|
||||||
using ScadaLink.AuditLog.Site;
|
using ScadaLink.AuditLog.Site;
|
||||||
using ScadaLink.AuditLog.Site.Telemetry;
|
using ScadaLink.AuditLog.Site.Telemetry;
|
||||||
@@ -129,6 +130,17 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddSingleton<ICachedCallLifecycleObserver>(
|
services.AddSingleton<ICachedCallLifecycleObserver>(
|
||||||
sp => sp.GetRequiredService<CachedCallLifecycleBridge>());
|
sp => sp.GetRequiredService<CachedCallLifecycleBridge>());
|
||||||
|
|
||||||
|
// M4 Bundle B: central direct-write audit writer used by
|
||||||
|
// NotificationOutboxActor (Bundle B) and Inbound API (Bundle C/D) to
|
||||||
|
// emit AuditLog rows that originate ON central, not via site telemetry.
|
||||||
|
// Singleton — the writer is stateless; its per-call scope opens a fresh
|
||||||
|
// IAuditLogRepository (a SCOPED EF Core service registered by
|
||||||
|
// ScadaLink.ConfigurationDatabase). The interface (ICentralAuditWriter)
|
||||||
|
// 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.
|
||||||
|
services.AddSingleton<ICentralAuditWriter, CentralAuditWriter>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -275,11 +275,18 @@ akka {{
|
|||||||
.GetRequiredService<IOptions<ScadaLink.NotificationOutbox.NotificationOutboxOptions>>().Value;
|
.GetRequiredService<IOptions<ScadaLink.NotificationOutbox.NotificationOutboxOptions>>().Value;
|
||||||
var outboxLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
|
var outboxLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
|
||||||
.CreateLogger<ScadaLink.NotificationOutbox.NotificationOutboxActor>();
|
.CreateLogger<ScadaLink.NotificationOutbox.NotificationOutboxActor>();
|
||||||
|
// M4 Bundle B: central direct-write audit writer for dispatcher attempt
|
||||||
|
// + terminal events. Resolved once from the root provider — the writer
|
||||||
|
// is a singleton and stateless, opening per-call DI scopes internally
|
||||||
|
// to resolve the scoped IAuditLogRepository.
|
||||||
|
var outboxAuditWriter = _serviceProvider
|
||||||
|
.GetRequiredService<ScadaLink.Commons.Interfaces.Services.ICentralAuditWriter>();
|
||||||
|
|
||||||
var outboxSingletonProps = ClusterSingletonManager.Props(
|
var outboxSingletonProps = ClusterSingletonManager.Props(
|
||||||
singletonProps: Props.Create(() => new ScadaLink.NotificationOutbox.NotificationOutboxActor(
|
singletonProps: Props.Create(() => new ScadaLink.NotificationOutbox.NotificationOutboxActor(
|
||||||
_serviceProvider,
|
_serviceProvider,
|
||||||
outboxOptions,
|
outboxOptions,
|
||||||
|
outboxAuditWriter,
|
||||||
outboxLogger)),
|
outboxLogger)),
|
||||||
terminationMessage: PoisonPill.Instance,
|
terminationMessage: PoisonPill.Instance,
|
||||||
settings: ClusterSingletonManagerSettings.Create(_actorSystem!)
|
settings: ClusterSingletonManagerSettings.Create(_actorSystem!)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using ScadaLink.Host;
|
|||||||
using ScadaLink.Host.Actors;
|
using ScadaLink.Host.Actors;
|
||||||
using ScadaLink.Host.Health;
|
using ScadaLink.Host.Health;
|
||||||
using ScadaLink.InboundAPI;
|
using ScadaLink.InboundAPI;
|
||||||
|
using ScadaLink.InboundAPI.Middleware;
|
||||||
using ScadaLink.ManagementService;
|
using ScadaLink.ManagementService;
|
||||||
using ScadaLink.NotificationOutbox;
|
using ScadaLink.NotificationOutbox;
|
||||||
using ScadaLink.NotificationService;
|
using ScadaLink.NotificationService;
|
||||||
@@ -162,6 +163,18 @@ try
|
|||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
|
|
||||||
|
// Audit Log #23 (M4 Bundle D, T8): emit one InboundRequest/InboundAuthFailure
|
||||||
|
// audit row per call into the inbound API. Placed AFTER UseAuthentication/
|
||||||
|
// UseAuthorization so any HttpContext.User the framework populates is in
|
||||||
|
// place, and scoped to the /api/ prefix so it never observes the Central UI,
|
||||||
|
// Management API, SignalR hubs, or health endpoints. The endpoint handler
|
||||||
|
// is responsible for stashing the resolved API key name on
|
||||||
|
// HttpContext.Items (see AuditWriteMiddleware.AuditActorItemKey) AFTER its
|
||||||
|
// in-handler API key validation succeeds.
|
||||||
|
app.UseWhen(
|
||||||
|
ctx => ctx.Request.Path.StartsWithSegments("/api"),
|
||||||
|
branch => branch.UseAuditWriteMiddleware());
|
||||||
|
|
||||||
// WP-12: Map readiness endpoint — returns 503 until ready, 200 when ready.
|
// WP-12: Map readiness endpoint — returns 503 until ready, 200 when ready.
|
||||||
// REQ-HOST-4a defines readiness as cluster membership + DB connectivity,
|
// REQ-HOST-4a defines readiness as cluster membership + DB connectivity,
|
||||||
// explicitly NOT cluster leadership. The leader-only "active-node" check is
|
// explicitly NOT cluster leadership. The leader-only "active-node" check is
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Routing;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ScadaLink.InboundAPI.Middleware;
|
||||||
|
|
||||||
namespace ScadaLink.InboundAPI;
|
namespace ScadaLink.InboundAPI;
|
||||||
|
|
||||||
@@ -53,6 +54,13 @@ public static class EndpointExtensions
|
|||||||
|
|
||||||
var method = validationResult.Method!;
|
var method = validationResult.Method!;
|
||||||
|
|
||||||
|
// Audit Log (#23 M4 Bundle D): publish the resolved API key name so
|
||||||
|
// AuditWriteMiddleware can populate AuditEvent.Actor in its finally
|
||||||
|
// block. Done AFTER validation succeeded — auth failures leave the
|
||||||
|
// slot empty and the middleware records the row with Actor=null.
|
||||||
|
httpContext.Items[AuditWriteMiddleware.AuditActorItemKey] =
|
||||||
|
validationResult.ApiKey!.Name;
|
||||||
|
|
||||||
// WP-2: Deserialize and validate parameters
|
// WP-2: Deserialize and validate parameters
|
||||||
JsonElement? body = null;
|
JsonElement? body = null;
|
||||||
try
|
try
|
||||||
|
|||||||
266
src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs
Normal file
266
src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.InboundAPI.Middleware;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (M4 Bundle D, T7) — emits one <see cref="AuditChannel.ApiInbound"/>
|
||||||
|
/// row per inbound API request via <see cref="ICentralAuditWriter"/> covering the
|
||||||
|
/// full set of response shapes:
|
||||||
|
///
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>2xx / non-error → <see cref="AuditKind.InboundRequest"/> with <see cref="AuditStatus.Delivered"/>.</description></item>
|
||||||
|
/// <item><description>401/403 → <see cref="AuditKind.InboundAuthFailure"/> with <see cref="AuditStatus.Failed"/>.</description></item>
|
||||||
|
/// <item><description>4xx (non-auth) / 5xx / thrown exception → <see cref="AuditKind.InboundRequest"/> with <see cref="AuditStatus.Failed"/>.</description></item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Best-effort contract (alog.md §13).</b> Audit emission NEVER alters the
|
||||||
|
/// user-facing HTTP response — a thrown writer or any other failure during
|
||||||
|
/// emission is caught, logged at warning, and dropped. A handler exception is
|
||||||
|
/// recorded on the audit row then re-thrown so the framework error path stays
|
||||||
|
/// authoritative.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Actor resolution.</b> Inbound API auth runs inside the endpoint handler
|
||||||
|
/// (no <c>UseAuthentication</c>-backed scheme populates <see cref="HttpContext.User"/>
|
||||||
|
/// for X-API-Key callers), so the handler stashes the resolved API key name on
|
||||||
|
/// <see cref="HttpContext.Items"/> under <see cref="AuditActorItemKey"/> after
|
||||||
|
/// <c>ApiKeyValidator.ValidateAsync</c> succeeds. The middleware reads it in
|
||||||
|
/// its <c>finally</c> block — on auth failures the key remains absent and
|
||||||
|
/// <see cref="AuditEvent.Actor"/> stays null (we never echo back an
|
||||||
|
/// unauthenticated principal).
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Body capture.</b> The request body is buffered via
|
||||||
|
/// <see cref="HttpRequestRewindExtensions.EnableBuffering(HttpRequest)"/> then
|
||||||
|
/// rewound so the downstream endpoint handler still sees the full payload.
|
||||||
|
/// Response body capture is deferred to M5 — wrapping <c>Response.Body</c>
|
||||||
|
/// requires a memory-stream swap that interacts awkwardly with Minimal API's
|
||||||
|
/// <c>Results.Json</c>/<c>Results.Text</c> writers; the M4 deliverable emits
|
||||||
|
/// the audit row with <see cref="AuditEvent.ResponseSummary"/> left null.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AuditWriteMiddleware
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="HttpContext.Items"/> key used by the endpoint handler to publish
|
||||||
|
/// the resolved API key name once <c>ApiKeyValidator.ValidateAsync</c> has
|
||||||
|
/// succeeded. Exposed as a constant so the handler and middleware share a
|
||||||
|
/// single source of truth (no stringly-typed coupling).
|
||||||
|
/// </summary>
|
||||||
|
public const string AuditActorItemKey = "ScadaLink.InboundAPI.AuditActor";
|
||||||
|
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ICentralAuditWriter _auditWriter;
|
||||||
|
private readonly ILogger<AuditWriteMiddleware> _logger;
|
||||||
|
|
||||||
|
public AuditWriteMiddleware(
|
||||||
|
RequestDelegate next,
|
||||||
|
ICentralAuditWriter auditWriter,
|
||||||
|
ILogger<AuditWriteMiddleware> logger)
|
||||||
|
{
|
||||||
|
_next = next ?? throw new ArgumentNullException(nameof(next));
|
||||||
|
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext ctx)
|
||||||
|
{
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
// Buffer the request body up front so we can both audit it and let the
|
||||||
|
// downstream handler still parse it. EnableBuffering swaps the request
|
||||||
|
// stream for a seekable wrapper that the framework rewinds at the end
|
||||||
|
// of the pipeline for us — but we also rewind to position 0 after our
|
||||||
|
// own read so the very next reader starts from the top.
|
||||||
|
ctx.Request.EnableBuffering();
|
||||||
|
var requestBody = await ReadBufferedRequestBodyAsync(ctx.Request).ConfigureAwait(false);
|
||||||
|
|
||||||
|
Exception? thrown = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _next(ctx).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
thrown = ex;
|
||||||
|
// Re-throw — audit emission is BEST EFFORT, but the user-facing
|
||||||
|
// request's own error path must remain authoritative (alog.md §13).
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
EmitInboundAudit(ctx, sw.ElapsedMilliseconds, thrown, requestBody);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds and writes the <see cref="AuditChannel.ApiInbound"/> row for the
|
||||||
|
/// request. Wrapped in try/catch so a thrown writer or any other emission
|
||||||
|
/// failure stays out of the user-facing response (alog.md §13).
|
||||||
|
/// </summary>
|
||||||
|
private void EmitInboundAudit(
|
||||||
|
HttpContext ctx,
|
||||||
|
long durationMs,
|
||||||
|
Exception? thrown,
|
||||||
|
string? requestBody)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var statusCode = ctx.Response.StatusCode;
|
||||||
|
var isAuthFailure = statusCode is 401 or 403;
|
||||||
|
|
||||||
|
var kind = isAuthFailure
|
||||||
|
? AuditKind.InboundAuthFailure
|
||||||
|
: AuditKind.InboundRequest;
|
||||||
|
|
||||||
|
// A thrown handler exception is always Failed; otherwise any 4xx/5xx
|
||||||
|
// response signals failure. 2xx/3xx are Delivered.
|
||||||
|
var status = (thrown != null || statusCode >= 400)
|
||||||
|
? AuditStatus.Failed
|
||||||
|
: AuditStatus.Delivered;
|
||||||
|
|
||||||
|
var actor = isAuthFailure ? null : ResolveActor(ctx);
|
||||||
|
var methodName = ResolveMethodName(ctx);
|
||||||
|
|
||||||
|
var extra = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
remoteIp = ctx.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
userAgent = ctx.Request.Headers.UserAgent.ToString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
var evt = new AuditEvent
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = DateTime.UtcNow,
|
||||||
|
Channel = AuditChannel.ApiInbound,
|
||||||
|
Kind = kind,
|
||||||
|
Actor = actor,
|
||||||
|
Target = methodName,
|
||||||
|
Status = status,
|
||||||
|
HttpStatus = statusCode,
|
||||||
|
DurationMs = (int)Math.Min(durationMs, int.MaxValue),
|
||||||
|
ErrorMessage = thrown?.Message,
|
||||||
|
RequestSummary = requestBody,
|
||||||
|
// Response body capture is deferred to M5 (see XML doc above).
|
||||||
|
ResponseSummary = null,
|
||||||
|
PayloadTruncated = false,
|
||||||
|
Extra = extra,
|
||||||
|
// Central direct-write — no site-local forwarding state.
|
||||||
|
ForwardState = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fire-and-forget — the writer itself swallows; the additional
|
||||||
|
// try/catch around the fire still protects us if WriteAsync throws
|
||||||
|
// synchronously before returning a task.
|
||||||
|
_ = _auditWriter.WriteAsync(evt);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
ex,
|
||||||
|
"AuditWriteMiddleware emission failed for {Method} {Path} (status {Status})",
|
||||||
|
ctx.Request.Method, ctx.Request.Path, ctx.Response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the buffered request body fully into a string and rewinds the
|
||||||
|
/// stream so the downstream handler sees the unconsumed payload. Returns
|
||||||
|
/// null for empty/missing bodies so the audit row's
|
||||||
|
/// <see cref="AuditEvent.RequestSummary"/> stays null rather than
|
||||||
|
/// containing an empty string.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<string?> ReadBufferedRequestBodyAsync(HttpRequest request)
|
||||||
|
{
|
||||||
|
if (request.ContentLength is 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
request.Body.Position = 0;
|
||||||
|
using var reader = new StreamReader(
|
||||||
|
request.Body,
|
||||||
|
Encoding.UTF8,
|
||||||
|
detectEncodingFromByteOrderMarks: false,
|
||||||
|
bufferSize: 1024,
|
||||||
|
leaveOpen: true);
|
||||||
|
var content = await reader.ReadToEndAsync().ConfigureAwait(false);
|
||||||
|
request.Body.Position = 0;
|
||||||
|
return string.IsNullOrEmpty(content) ? null : content;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// A failed body read must not abort the request — fall through
|
||||||
|
// with a null RequestSummary; the audit row still records the
|
||||||
|
// outcome.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the API key name the endpoint handler stashed on
|
||||||
|
/// <see cref="HttpContext.Items"/> after successful auth. Falls back to
|
||||||
|
/// the authenticated user name when an ASP.NET scheme has populated
|
||||||
|
/// <see cref="HttpContext.User"/> (defensive — currently unused for inbound
|
||||||
|
/// API but cheap and forward-compatible).
|
||||||
|
/// </summary>
|
||||||
|
private static string? ResolveActor(HttpContext ctx)
|
||||||
|
{
|
||||||
|
if (ctx.Items.TryGetValue(AuditActorItemKey, out var stashed)
|
||||||
|
&& stashed is string name
|
||||||
|
&& !string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = ctx.User;
|
||||||
|
if (user?.Identity is { IsAuthenticated: true, Name: { Length: > 0 } userName })
|
||||||
|
{
|
||||||
|
return userName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pulls the <c>{methodName}</c> route value off the request. Falls back to
|
||||||
|
/// the last segment of <see cref="HttpRequest.Path"/> when no route value
|
||||||
|
/// is bound (e.g. when the request never reached the matched endpoint).
|
||||||
|
/// </summary>
|
||||||
|
private static string? ResolveMethodName(HttpContext ctx)
|
||||||
|
{
|
||||||
|
if (ctx.Request.RouteValues.TryGetValue("methodName", out var raw)
|
||||||
|
&& raw is string method
|
||||||
|
&& !string.IsNullOrWhiteSpace(method))
|
||||||
|
{
|
||||||
|
return method;
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = ctx.Request.Path.Value;
|
||||||
|
if (string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastSlash = path.LastIndexOf('/');
|
||||||
|
if (lastSlash < 0 || lastSlash == path.Length - 1)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path[(lastSlash + 1)..];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
|
||||||
|
namespace ScadaLink.InboundAPI.Middleware;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="IApplicationBuilder"/> extensions for wiring
|
||||||
|
/// <see cref="AuditWriteMiddleware"/> into the ASP.NET Core request pipeline.
|
||||||
|
/// See <see cref="AuditWriteMiddleware"/> for the placement contract (must run
|
||||||
|
/// after auth so the resolved API key name is available on
|
||||||
|
/// <see cref="Microsoft.AspNetCore.Http.HttpContext.Items"/>, and before the
|
||||||
|
/// inbound-API endpoint handler that owns script execution).
|
||||||
|
/// </summary>
|
||||||
|
public static class AuditWriteMiddlewareExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers <see cref="AuditWriteMiddleware"/> in the pipeline.
|
||||||
|
/// <see cref="ScadaLink.Commons.Interfaces.Services.ICentralAuditWriter"/>
|
||||||
|
/// must be registered in DI (typically via <c>AddAuditLog</c>) before this
|
||||||
|
/// middleware runs.
|
||||||
|
/// </summary>
|
||||||
|
public static IApplicationBuilder UseAuditWriteMiddleware(this IApplicationBuilder app)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(app);
|
||||||
|
return app.UseMiddleware<AuditWriteMiddleware>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Entities.Notifications;
|
using ScadaLink.Commons.Entities.Notifications;
|
||||||
using ScadaLink.Commons.Interfaces.Repositories;
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
using ScadaLink.Commons.Messages.Notification;
|
using ScadaLink.Commons.Messages.Notification;
|
||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
using ScadaLink.Commons.Types.Notifications;
|
using ScadaLink.Commons.Types.Notifications;
|
||||||
@@ -30,6 +32,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
|
|
||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly NotificationOutboxOptions _options;
|
private readonly NotificationOutboxOptions _options;
|
||||||
|
private readonly ICentralAuditWriter _auditWriter;
|
||||||
private readonly ILogger<NotificationOutboxActor> _logger;
|
private readonly ILogger<NotificationOutboxActor> _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -45,11 +48,13 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
public NotificationOutboxActor(
|
public NotificationOutboxActor(
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
NotificationOutboxOptions options,
|
NotificationOutboxOptions options,
|
||||||
|
ICentralAuditWriter auditWriter,
|
||||||
ILogger<NotificationOutboxActor> logger)
|
ILogger<NotificationOutboxActor> logger)
|
||||||
{
|
{
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||||
_options = options;
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
_logger = logger;
|
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
|
||||||
Receive<NotificationSubmit>(HandleSubmit);
|
Receive<NotificationSubmit>(HandleSubmit);
|
||||||
Receive<InternalMessages.IngestPersisted>(HandleIngestPersisted);
|
Receive<InternalMessages.IngestPersisted>(HandleIngestPersisted);
|
||||||
@@ -265,6 +270,26 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
/// status transition. A missing adapter parks the notification; otherwise the
|
/// status transition. A missing adapter parks the notification; otherwise the
|
||||||
/// <see cref="DeliveryOutcome"/> drives the transition. The updated row is always persisted.
|
/// <see cref="DeliveryOutcome"/> drives the transition. The updated row is always persisted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// M4 Bundle B2 + B3: a single
|
||||||
|
/// <see cref="AuditChannel.Notification"/>/<see cref="AuditKind.NotifyDeliver"/>
|
||||||
|
/// row is emitted with <see cref="AuditStatus.Attempted"/> per attempt
|
||||||
|
/// (success, transient, permanent); when the post-outcome status is a
|
||||||
|
/// terminal one (Delivered, Parked) a SECOND row is emitted carrying
|
||||||
|
/// that terminal status. Both emissions are wrapped in a try/catch so a
|
||||||
|
/// thrown audit writer NEVER aborts the user-facing dispatch — the
|
||||||
|
/// <see cref="CentralAuditWriter"/> itself swallows internal failures,
|
||||||
|
/// but the dispatcher wraps defensively per alog.md §13. The
|
||||||
|
/// missing-adapter park path also emits both rows because it IS an
|
||||||
|
/// attempt that resolved to a park from the dispatcher's point of view.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Attempt duration is measured around the adapter call and recorded on
|
||||||
|
/// the Attempted row so downstream KPIs can compute per-attempt latency
|
||||||
|
/// without joining to the row update timestamps.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
private async Task DeliverOneAsync(
|
private async Task DeliverOneAsync(
|
||||||
Notification notification,
|
Notification notification,
|
||||||
DateTimeOffset now,
|
DateTimeOffset now,
|
||||||
@@ -275,14 +300,29 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
{
|
{
|
||||||
if (!adapters.TryGetValue(notification.Type, out var adapter))
|
if (!adapters.TryGetValue(notification.Type, out var adapter))
|
||||||
{
|
{
|
||||||
|
// Missing-adapter park: from the dispatcher's perspective this is an
|
||||||
|
// attempt that resolved to a terminal park. Emit Attempted then the
|
||||||
|
// terminal Parked row, both carrying the same explanatory error.
|
||||||
|
var missingAdapterError = $"no delivery adapter for type {notification.Type}";
|
||||||
notification.Status = NotificationStatus.Parked;
|
notification.Status = NotificationStatus.Parked;
|
||||||
notification.LastError = $"no delivery adapter for type {notification.Type}";
|
notification.LastError = missingAdapterError;
|
||||||
notification.LastAttemptAt = now;
|
notification.LastAttemptAt = now;
|
||||||
await outboxRepository.UpdateAsync(notification);
|
await outboxRepository.UpdateAsync(notification);
|
||||||
|
EmitAttemptAudit(
|
||||||
|
notification,
|
||||||
|
now,
|
||||||
|
durationMs: 0,
|
||||||
|
errorMessage: missingAdapterError);
|
||||||
|
EmitTerminalAudit(notification, now, errorMessage: missingAdapterError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Measure the attempt duration around the adapter call so the
|
||||||
|
// Attempted row carries it for KPI use.
|
||||||
|
var attemptStart = DateTimeOffset.UtcNow;
|
||||||
var outcome = await adapter.DeliverAsync(notification);
|
var outcome = await adapter.DeliverAsync(notification);
|
||||||
|
var durationMs = (int)Math.Min(
|
||||||
|
int.MaxValue, Math.Max(0, (DateTimeOffset.UtcNow - attemptStart).TotalMilliseconds));
|
||||||
|
|
||||||
switch (outcome.Result)
|
switch (outcome.Result)
|
||||||
{
|
{
|
||||||
@@ -317,6 +357,158 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
}
|
}
|
||||||
|
|
||||||
await outboxRepository.UpdateAsync(notification);
|
await outboxRepository.UpdateAsync(notification);
|
||||||
|
|
||||||
|
// Emit the per-attempt Attempted row exactly once regardless of the
|
||||||
|
// outcome (B2). The error message comes from the outcome, not from
|
||||||
|
// notification.LastError, so a success row is null and a transient
|
||||||
|
// row carries the SMTP failure reason verbatim.
|
||||||
|
EmitAttemptAudit(
|
||||||
|
notification,
|
||||||
|
now,
|
||||||
|
durationMs: durationMs,
|
||||||
|
errorMessage: outcome.Result == DeliveryResult.Success ? null : outcome.Error);
|
||||||
|
|
||||||
|
// If the post-outcome status is terminal (Delivered or Parked — the
|
||||||
|
// dispatcher never sets Discarded; that lives on the manual discard
|
||||||
|
// path), emit the terminal NotifyDeliver row (B3). The error message
|
||||||
|
// on a Delivered terminal is null; on Parked it carries the outcome's
|
||||||
|
// reason so downstream consumers can link Attempted+Parked rows.
|
||||||
|
if (IsTerminal(notification.Status))
|
||||||
|
{
|
||||||
|
EmitTerminalAudit(
|
||||||
|
notification,
|
||||||
|
now,
|
||||||
|
errorMessage: outcome.Result == DeliveryResult.Success ? null : outcome.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True for <see cref="NotificationStatus.Delivered"/>,
|
||||||
|
/// <see cref="NotificationStatus.Parked"/>, or
|
||||||
|
/// <see cref="NotificationStatus.Discarded"/> — the three terminal states
|
||||||
|
/// on the central outbox lifecycle. Used by the dispatcher and the manual
|
||||||
|
/// discard handler to decide when to emit the terminal NotifyDeliver row.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsTerminal(NotificationStatus status)
|
||||||
|
{
|
||||||
|
return status is NotificationStatus.Delivered
|
||||||
|
or NotificationStatus.Parked
|
||||||
|
or NotificationStatus.Discarded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emits a single
|
||||||
|
/// <see cref="AuditChannel.Notification"/>/<see cref="AuditKind.NotifyDeliver"/>
|
||||||
|
/// audit row carrying the terminal status (Delivered, Parked, or
|
||||||
|
/// Discarded) of <paramref name="notification"/>. Wrapped in try/catch
|
||||||
|
/// for the same defensive reason as <see cref="EmitAttemptAudit"/>.
|
||||||
|
/// </summary>
|
||||||
|
private void EmitTerminalAudit(
|
||||||
|
Notification notification,
|
||||||
|
DateTimeOffset now,
|
||||||
|
string? errorMessage)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var terminalStatus = MapNotificationStatusToAuditStatus(notification.Status);
|
||||||
|
var evt = BuildNotifyDeliverEvent(notification, now, terminalStatus, errorMessage);
|
||||||
|
_ = _auditWriter.WriteAsync(evt);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
ex,
|
||||||
|
"Failed to emit terminal {Status} audit row for notification {NotificationId}.",
|
||||||
|
notification.Status, notification.NotificationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps the central-outbox <see cref="NotificationStatus"/> terminal
|
||||||
|
/// values onto the corresponding <see cref="AuditStatus"/> values used by
|
||||||
|
/// AuditLog (#23). Non-terminal statuses throw — the caller must gate on
|
||||||
|
/// <see cref="IsTerminal"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static AuditStatus MapNotificationStatusToAuditStatus(NotificationStatus status)
|
||||||
|
{
|
||||||
|
return status switch
|
||||||
|
{
|
||||||
|
NotificationStatus.Delivered => AuditStatus.Delivered,
|
||||||
|
NotificationStatus.Parked => AuditStatus.Parked,
|
||||||
|
NotificationStatus.Discarded => AuditStatus.Discarded,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(status), status, "non-terminal status has no audit terminal mapping"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emits a single
|
||||||
|
/// <see cref="AuditChannel.Notification"/>/<see cref="AuditKind.NotifyDeliver"/>
|
||||||
|
/// audit row with <see cref="AuditStatus.Attempted"/>. Wrapped in
|
||||||
|
/// try/catch so an audit-write failure never propagates back into the
|
||||||
|
/// dispatcher loop — the <see cref="CentralAuditWriter"/> already
|
||||||
|
/// swallows, this is defensive (alog.md §13).
|
||||||
|
/// </summary>
|
||||||
|
private void EmitAttemptAudit(
|
||||||
|
Notification notification,
|
||||||
|
DateTimeOffset now,
|
||||||
|
int durationMs,
|
||||||
|
string? errorMessage)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var evt = BuildNotifyDeliverEvent(notification, now, AuditStatus.Attempted, errorMessage)
|
||||||
|
with { DurationMs = durationMs };
|
||||||
|
// Fire-and-forget — we do NOT await: the dispatcher loop must not
|
||||||
|
// be blocked by audit IO, and the writer swallows its own faults.
|
||||||
|
// PipeTo is not used because the writer never throws.
|
||||||
|
_ = _auditWriter.WriteAsync(evt);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
ex,
|
||||||
|
"Failed to emit Attempted audit row for notification {NotificationId}.",
|
||||||
|
notification.NotificationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a <see cref="AuditChannel.Notification"/>/<see cref="AuditKind.NotifyDeliver"/>
|
||||||
|
/// row with the per-notification provenance fields (correlation id, list
|
||||||
|
/// name, source site/instance/script) populated from
|
||||||
|
/// <paramref name="notification"/>. <see cref="AuditEvent.CorrelationId"/>
|
||||||
|
/// parses the notification's id as a Guid; sites generate the id with
|
||||||
|
/// <c>Guid.NewGuid().ToString("N")</c> so the parse always succeeds, but
|
||||||
|
/// a non-Guid id is recorded as null rather than crashing the dispatcher.
|
||||||
|
/// </summary>
|
||||||
|
private static AuditEvent BuildNotifyDeliverEvent(
|
||||||
|
Notification notification,
|
||||||
|
DateTimeOffset now,
|
||||||
|
AuditStatus status,
|
||||||
|
string? errorMessage)
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
// Central dispatch — no authenticated actor (the originating
|
||||||
|
// script's identity is captured on the upstream NotifySend row).
|
||||||
|
Actor = null,
|
||||||
|
SourceSiteId = notification.SourceSiteId,
|
||||||
|
SourceInstanceId = notification.SourceInstanceId,
|
||||||
|
SourceScript = notification.SourceScript,
|
||||||
|
Target = notification.ListName,
|
||||||
|
Status = status,
|
||||||
|
ErrorMessage = errorMessage,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -563,6 +755,13 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
notification.Status = NotificationStatus.Discarded;
|
notification.Status = NotificationStatus.Discarded;
|
||||||
await repository.UpdateAsync(notification);
|
await repository.UpdateAsync(notification);
|
||||||
|
|
||||||
|
// M4 Bundle B3: a manual discard is the OTHER code path that produces
|
||||||
|
// a terminal NotificationStatus transition (alongside the dispatcher).
|
||||||
|
// Emit a Discarded NotifyDeliver row to match the dispatcher's
|
||||||
|
// Delivered/Parked emissions; the row carries no error message because
|
||||||
|
// the discard is an operator-driven cancellation, not a delivery error.
|
||||||
|
EmitTerminalAudit(notification, DateTimeOffset.UtcNow, errorMessage: null);
|
||||||
|
|
||||||
return new DiscardNotificationResponse(request.CorrelationId, Success: true, ErrorMessage: null);
|
return new DiscardNotificationResponse(request.CorrelationId, Success: true, ErrorMessage: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,13 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="ScadaLink.NotificationOutbox.Tests" />
|
<InternalsVisibleTo Include="ScadaLink.NotificationOutbox.Tests" />
|
||||||
|
<!--
|
||||||
|
Audit Log #23 (M4 Bundle E — Task E2): the cross-project
|
||||||
|
NotifyDispatcherAuditTrailTests need to drive the dispatcher loop
|
||||||
|
deterministically via the internal InternalMessages.DispatchTick.Instance
|
||||||
|
sentinel (same pattern the existing NotificationOutbox.Tests use).
|
||||||
|
-->
|
||||||
|
<InternalsVisibleTo Include="ScadaLink.AuditLog.Tests" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -22,6 +22,14 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="ScadaLink.SiteRuntime.Tests" />
|
<InternalsVisibleTo Include="ScadaLink.SiteRuntime.Tests" />
|
||||||
|
<!--
|
||||||
|
Audit Log #23 (M4 Bundle E — Task E1): the cross-project
|
||||||
|
DatabaseSyncEmissionEndToEndTests construct ScriptRuntimeContext.DatabaseHelper
|
||||||
|
directly (it has an internal ctor) so the test can drive the production
|
||||||
|
AuditingDbConnection wrapper end-to-end against a real MSSQL central
|
||||||
|
AuditLog. Same pattern as ScadaLink.SiteRuntime.Tests.
|
||||||
|
-->
|
||||||
|
<InternalsVisibleTo Include="ScadaLink.AuditLog.Tests" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
522
src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs
Normal file
522
src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs
Normal file
@@ -0,0 +1,522 @@
|
|||||||
|
using System.Data;
|
||||||
|
using System.Data.Common;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.SiteRuntime.Scripts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 — M4 Bundle A: <see cref="DbCommand"/> decorator that emits
|
||||||
|
/// exactly one <c>DbOutbound</c>/<c>DbWrite</c> audit event per execution.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Vocabulary lock (M4 plan):</b> both writes (Execute / ExecuteScalar) and
|
||||||
|
/// reads (ExecuteReader) emit <see cref="AuditKind.DbWrite"/> on the
|
||||||
|
/// <see cref="AuditChannel.DbOutbound"/> channel. The <c>Extra</c> JSON column
|
||||||
|
/// distinguishes them — <c>{"op":"write","rowsAffected":N}</c> for writes,
|
||||||
|
/// <c>{"op":"read","rowsReturned":N}</c> for reads.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Best-effort emission (alog.md §7):</b> mirrors
|
||||||
|
/// <see cref="ScriptRuntimeContext.ExternalSystemHelper"/>'s 3-layer fail-safe.
|
||||||
|
/// The original ADO.NET result (or original exception) flows back to the
|
||||||
|
/// script untouched; audit-build, audit-write, and audit-continuation faults
|
||||||
|
/// are all logged + swallowed. A faulted <see cref="IAuditWriter"/> never
|
||||||
|
/// aborts the SQL call.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class AuditingDbCommand : DbCommand
|
||||||
|
{
|
||||||
|
private readonly DbCommand _inner;
|
||||||
|
private readonly IAuditWriter _auditWriter;
|
||||||
|
private readonly string _connectionName;
|
||||||
|
private readonly string _siteId;
|
||||||
|
private readonly string _instanceName;
|
||||||
|
private readonly string? _sourceScript;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
private DbConnection? _wrappingConnection;
|
||||||
|
|
||||||
|
public AuditingDbCommand(
|
||||||
|
DbCommand inner,
|
||||||
|
IAuditWriter auditWriter,
|
||||||
|
string connectionName,
|
||||||
|
string siteId,
|
||||||
|
string instanceName,
|
||||||
|
string? sourceScript,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||||
|
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
|
||||||
|
_connectionName = connectionName ?? throw new ArgumentNullException(nameof(connectionName));
|
||||||
|
_siteId = siteId ?? string.Empty;
|
||||||
|
_instanceName = instanceName ?? string.Empty;
|
||||||
|
_sourceScript = sourceScript;
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Forwarded surface ------------------------------------------------
|
||||||
|
|
||||||
|
#pragma warning disable CS8765 // ADO.NET base members carry pre-NRT signatures with permissive nullability
|
||||||
|
public override string CommandText
|
||||||
|
{
|
||||||
|
get => _inner.CommandText;
|
||||||
|
set => _inner.CommandText = value;
|
||||||
|
}
|
||||||
|
#pragma warning restore CS8765
|
||||||
|
|
||||||
|
public override int CommandTimeout
|
||||||
|
{
|
||||||
|
get => _inner.CommandTimeout;
|
||||||
|
set => _inner.CommandTimeout = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override CommandType CommandType
|
||||||
|
{
|
||||||
|
get => _inner.CommandType;
|
||||||
|
set => _inner.CommandType = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool DesignTimeVisible
|
||||||
|
{
|
||||||
|
get => _inner.DesignTimeVisible;
|
||||||
|
set => _inner.DesignTimeVisible = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override UpdateRowSource UpdatedRowSource
|
||||||
|
{
|
||||||
|
get => _inner.UpdatedRowSource;
|
||||||
|
set => _inner.UpdatedRowSource = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override DbConnection? DbConnection
|
||||||
|
{
|
||||||
|
// When the script has wrapped the connection (the normal path through
|
||||||
|
// ScriptRuntimeContext.DatabaseHelper.Connection) we keep returning
|
||||||
|
// the wrapper, but writes from the user go through to the inner
|
||||||
|
// command so the underlying provider keeps its wiring intact.
|
||||||
|
get => _wrappingConnection ?? _inner.Connection;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_wrappingConnection = value;
|
||||||
|
_inner.Connection = value switch
|
||||||
|
{
|
||||||
|
AuditingDbConnection auditing => auditing.GetType()
|
||||||
|
.GetField("_inner", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)
|
||||||
|
!.GetValue(auditing) as DbConnection,
|
||||||
|
_ => value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override DbParameterCollection DbParameterCollection => _inner.Parameters;
|
||||||
|
|
||||||
|
protected override DbTransaction? DbTransaction
|
||||||
|
{
|
||||||
|
get => _inner.Transaction;
|
||||||
|
set => _inner.Transaction = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Cancel() => _inner.Cancel();
|
||||||
|
|
||||||
|
public override void Prepare() => _inner.Prepare();
|
||||||
|
|
||||||
|
protected override DbParameter CreateDbParameter() => _inner.CreateParameter();
|
||||||
|
|
||||||
|
// -- Audited execution surface ---------------------------------------
|
||||||
|
|
||||||
|
public override int ExecuteNonQuery()
|
||||||
|
{
|
||||||
|
var occurredAtUtc = DateTime.UtcNow;
|
||||||
|
var startTicks = Stopwatch.GetTimestamp();
|
||||||
|
int rows = 0;
|
||||||
|
Exception? thrown = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
rows = _inner.ExecuteNonQuery();
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
thrown = ex;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
EmitAudit(
|
||||||
|
occurredAtUtc,
|
||||||
|
ElapsedMs(startTicks),
|
||||||
|
op: "write",
|
||||||
|
rowsAffected: thrown == null ? rows : (int?)null,
|
||||||
|
rowsReturned: null,
|
||||||
|
thrown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override object? ExecuteScalar()
|
||||||
|
{
|
||||||
|
var occurredAtUtc = DateTime.UtcNow;
|
||||||
|
var startTicks = Stopwatch.GetTimestamp();
|
||||||
|
object? scalar = null;
|
||||||
|
Exception? thrown = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
scalar = _inner.ExecuteScalar();
|
||||||
|
return scalar;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
thrown = ex;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// ExecuteScalar is classified as "write" per the M4 vocabulary
|
||||||
|
// lock — it's a single-value execution; rowsAffected mirrors the
|
||||||
|
// inner command's value if exposed (DbCommand has no RecordsAffected
|
||||||
|
// property, so we report -1 when the provider didn't surface it).
|
||||||
|
EmitAudit(
|
||||||
|
occurredAtUtc,
|
||||||
|
ElapsedMs(startTicks),
|
||||||
|
op: "write",
|
||||||
|
rowsAffected: thrown == null ? -1 : (int?)null,
|
||||||
|
rowsReturned: null,
|
||||||
|
thrown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<int> ExecuteNonQueryAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var occurredAtUtc = DateTime.UtcNow;
|
||||||
|
var startTicks = Stopwatch.GetTimestamp();
|
||||||
|
int rows = 0;
|
||||||
|
Exception? thrown = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
rows = await _inner.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
thrown = ex;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
EmitAudit(
|
||||||
|
occurredAtUtc,
|
||||||
|
ElapsedMs(startTicks),
|
||||||
|
op: "write",
|
||||||
|
rowsAffected: thrown == null ? rows : (int?)null,
|
||||||
|
rowsReturned: null,
|
||||||
|
thrown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<object?> ExecuteScalarAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var occurredAtUtc = DateTime.UtcNow;
|
||||||
|
var startTicks = Stopwatch.GetTimestamp();
|
||||||
|
object? scalar = null;
|
||||||
|
Exception? thrown = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
scalar = await _inner.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return scalar;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
thrown = ex;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
EmitAudit(
|
||||||
|
occurredAtUtc,
|
||||||
|
ElapsedMs(startTicks),
|
||||||
|
op: "write",
|
||||||
|
rowsAffected: thrown == null ? -1 : (int?)null,
|
||||||
|
rowsReturned: null,
|
||||||
|
thrown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior)
|
||||||
|
{
|
||||||
|
var occurredAtUtc = DateTime.UtcNow;
|
||||||
|
var startTicks = Stopwatch.GetTimestamp();
|
||||||
|
DbDataReader? reader = null;
|
||||||
|
Exception? thrown = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
reader = _inner.ExecuteReader(behavior);
|
||||||
|
return new AuditingDbDataReader(
|
||||||
|
reader,
|
||||||
|
onClose: rows => EmitAudit(
|
||||||
|
occurredAtUtc,
|
||||||
|
ElapsedMs(startTicks),
|
||||||
|
op: "read",
|
||||||
|
rowsAffected: null,
|
||||||
|
rowsReturned: rows,
|
||||||
|
thrown: null));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
thrown = ex;
|
||||||
|
// Emit the failure row immediately — no reader to wait on.
|
||||||
|
EmitAudit(
|
||||||
|
occurredAtUtc,
|
||||||
|
ElapsedMs(startTicks),
|
||||||
|
op: "read",
|
||||||
|
rowsAffected: null,
|
||||||
|
rowsReturned: null,
|
||||||
|
thrown);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<DbDataReader> ExecuteDbDataReaderAsync(
|
||||||
|
CommandBehavior behavior, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var occurredAtUtc = DateTime.UtcNow;
|
||||||
|
var startTicks = Stopwatch.GetTimestamp();
|
||||||
|
DbDataReader? reader = null;
|
||||||
|
Exception? thrown = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
reader = await _inner.ExecuteReaderAsync(behavior, cancellationToken).ConfigureAwait(false);
|
||||||
|
return new AuditingDbDataReader(
|
||||||
|
reader,
|
||||||
|
onClose: rows => EmitAudit(
|
||||||
|
occurredAtUtc,
|
||||||
|
ElapsedMs(startTicks),
|
||||||
|
op: "read",
|
||||||
|
rowsAffected: null,
|
||||||
|
rowsReturned: rows,
|
||||||
|
thrown: null));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
thrown = ex;
|
||||||
|
EmitAudit(
|
||||||
|
occurredAtUtc,
|
||||||
|
ElapsedMs(startTicks),
|
||||||
|
op: "read",
|
||||||
|
rowsAffected: null,
|
||||||
|
rowsReturned: null,
|
||||||
|
thrown);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
_inner.Dispose();
|
||||||
|
}
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Emission ---------------------------------------------------------
|
||||||
|
|
||||||
|
private static int ElapsedMs(long startTicks) =>
|
||||||
|
(int)((Stopwatch.GetTimestamp() - startTicks) * 1000d / Stopwatch.Frequency);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort emission of one <c>DbOutbound</c>/<c>DbWrite</c> audit row.
|
||||||
|
/// Mirrors the M2 Bundle F <c>EmitCallAudit</c> 3-layer fail-safe pattern.
|
||||||
|
/// </summary>
|
||||||
|
private void EmitAudit(
|
||||||
|
DateTime occurredAtUtc,
|
||||||
|
int durationMs,
|
||||||
|
string op,
|
||||||
|
int? rowsAffected,
|
||||||
|
int? rowsReturned,
|
||||||
|
Exception? thrown)
|
||||||
|
{
|
||||||
|
AuditEvent evt;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
evt = BuildAuditEvent(occurredAtUtc, durationMs, op, rowsAffected, rowsReturned, thrown);
|
||||||
|
}
|
||||||
|
catch (Exception buildEx)
|
||||||
|
{
|
||||||
|
// Defensive: building the event from already-validated fields
|
||||||
|
// shouldn't throw, but the alog.md §7 contract requires we never
|
||||||
|
// propagate to the user-facing action regardless.
|
||||||
|
_logger.LogWarning(buildEx,
|
||||||
|
"Failed to build Audit Log #23 event for {Connection} (op={Op}) — skipping emission",
|
||||||
|
_connectionName, op);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var writeTask = _auditWriter.WriteAsync(evt, CancellationToken.None);
|
||||||
|
if (!writeTask.IsCompleted)
|
||||||
|
{
|
||||||
|
writeTask.ContinueWith(
|
||||||
|
t => _logger.LogWarning(t.Exception,
|
||||||
|
"Audit Log #23 write failed for EventId {EventId} ({Connection} op={Op})",
|
||||||
|
evt.EventId, _connectionName, op),
|
||||||
|
CancellationToken.None,
|
||||||
|
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||||
|
TaskScheduler.Default);
|
||||||
|
}
|
||||||
|
else if (writeTask.IsFaulted)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(writeTask.Exception,
|
||||||
|
"Audit Log #23 write failed for EventId {EventId} ({Connection} op={Op})",
|
||||||
|
evt.EventId, _connectionName, op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception writeEx)
|
||||||
|
{
|
||||||
|
// Synchronous throw from WriteAsync before its own try/catch.
|
||||||
|
// Swallow + log per alog.md §7.
|
||||||
|
_logger.LogWarning(writeEx,
|
||||||
|
"Audit Log #23 write threw synchronously for EventId {EventId} ({Connection} op={Op})",
|
||||||
|
evt.EventId, _connectionName, op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuditEvent BuildAuditEvent(
|
||||||
|
DateTime occurredAtUtc,
|
||||||
|
int durationMs,
|
||||||
|
string op,
|
||||||
|
int? rowsAffected,
|
||||||
|
int? rowsReturned,
|
||||||
|
Exception? thrown)
|
||||||
|
{
|
||||||
|
var status = thrown == null ? AuditStatus.Delivered : AuditStatus.Failed;
|
||||||
|
|
||||||
|
// Target = "<connectionName>.<first 60 chars of SQL>" so the audit
|
||||||
|
// row carries a human-recognisable handle without dragging the full
|
||||||
|
// (potentially huge) statement into the index column. The full
|
||||||
|
// statement + parameter values live in RequestSummary.
|
||||||
|
string target = _connectionName;
|
||||||
|
var sqlSnippet = _inner.CommandText ?? string.Empty;
|
||||||
|
if (sqlSnippet.Length > 0)
|
||||||
|
{
|
||||||
|
var snippet = sqlSnippet.Length > 60
|
||||||
|
? sqlSnippet[..60]
|
||||||
|
: sqlSnippet;
|
||||||
|
target = $"{_connectionName}.{snippet}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestSummary captures the SQL statement + parameter values by
|
||||||
|
// default per the alog.md M4 acceptance criteria (M5 will add
|
||||||
|
// per-connection redaction opt-in).
|
||||||
|
string? requestSummary = BuildRequestSummary();
|
||||||
|
|
||||||
|
// Extra carries the op discriminator + row count per the vocabulary
|
||||||
|
// lock. Build as a small hand-rolled JSON object to avoid pulling
|
||||||
|
// in System.Text.Json on the hot path.
|
||||||
|
string extra = op == "write"
|
||||||
|
? $"{{\"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,
|
||||||
|
CorrelationId = null,
|
||||||
|
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
||||||
|
SourceInstanceId = _instanceName,
|
||||||
|
SourceScript = _sourceScript,
|
||||||
|
Actor = null,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compose a JSON request summary capturing the SQL statement and
|
||||||
|
/// parameter values. Parameter values are captured by default per the
|
||||||
|
/// M4 acceptance criteria — redaction is opt-in and deferred to M5.
|
||||||
|
/// </summary>
|
||||||
|
private string? BuildRequestSummary()
|
||||||
|
{
|
||||||
|
var sql = _inner.CommandText;
|
||||||
|
if (string.IsNullOrEmpty(sql))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand-roll the JSON so we don't pull in System.Text.Json for a
|
||||||
|
// shape this small. Values are stringified with ToString() — fully
|
||||||
|
// structured serialisation arrives with the redaction work in M5.
|
||||||
|
var sb = new System.Text.StringBuilder(sql.Length + 64);
|
||||||
|
sb.Append("{\"sql\":");
|
||||||
|
AppendJsonString(sb, sql);
|
||||||
|
|
||||||
|
if (_inner.Parameters.Count > 0)
|
||||||
|
{
|
||||||
|
sb.Append(",\"parameters\":{");
|
||||||
|
var first = true;
|
||||||
|
foreach (DbParameter p in _inner.Parameters)
|
||||||
|
{
|
||||||
|
if (!first) sb.Append(',');
|
||||||
|
first = false;
|
||||||
|
AppendJsonString(sb, p.ParameterName);
|
||||||
|
sb.Append(':');
|
||||||
|
if (p.Value is null || p.Value is DBNull)
|
||||||
|
{
|
||||||
|
sb.Append("null");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AppendJsonString(sb, p.Value.ToString() ?? string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.Append('}');
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append('}');
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendJsonString(System.Text.StringBuilder sb, string value)
|
||||||
|
{
|
||||||
|
sb.Append('"');
|
||||||
|
foreach (var ch in value)
|
||||||
|
{
|
||||||
|
switch (ch)
|
||||||
|
{
|
||||||
|
case '"': sb.Append("\\\""); break;
|
||||||
|
case '\\': sb.Append("\\\\"); break;
|
||||||
|
case '\b': sb.Append("\\b"); break;
|
||||||
|
case '\f': sb.Append("\\f"); break;
|
||||||
|
case '\n': sb.Append("\\n"); break;
|
||||||
|
case '\r': sb.Append("\\r"); break;
|
||||||
|
case '\t': sb.Append("\\t"); break;
|
||||||
|
default:
|
||||||
|
if (ch < 0x20)
|
||||||
|
{
|
||||||
|
sb.Append("\\u").Append(((int)ch).ToString("x4"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append(ch);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.Append('"');
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs
Normal file
116
src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
using System.Data;
|
||||||
|
using System.Data.Common;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
|
||||||
|
namespace ScadaLink.SiteRuntime.Scripts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 — M4 Bundle A: thin decorator over the
|
||||||
|
/// <see cref="DbConnection"/> returned by
|
||||||
|
/// <see cref="ScriptRuntimeContext.DatabaseHelper.Connection"/>. The decorator
|
||||||
|
/// itself does no audit work — it simply intercepts
|
||||||
|
/// <see cref="CreateDbCommand"/> so the <see cref="DbCommand"/> handed back to
|
||||||
|
/// the script is wrapped in an <see cref="AuditingDbCommand"/> that emits one
|
||||||
|
/// <c>DbOutbound</c>/<c>DbWrite</c> audit row per execution.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// All other <see cref="DbConnection"/> members forward to the inner connection
|
||||||
|
/// unchanged so the script keeps full ADO.NET semantics (transactions, state
|
||||||
|
/// transitions, server-version queries, etc.). Disposing the wrapper disposes
|
||||||
|
/// the inner connection — the caller is still responsible for disposal per
|
||||||
|
/// the <see cref="IDatabaseGateway"/> contract.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The audit-write failure contract (alog.md §7) is honoured at the
|
||||||
|
/// <see cref="AuditingDbCommand"/> layer — see that class for the 3-layer
|
||||||
|
/// fail-safe pattern (build, write, observe).
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class AuditingDbConnection : DbConnection
|
||||||
|
{
|
||||||
|
private readonly DbConnection _inner;
|
||||||
|
private readonly IAuditWriter _auditWriter;
|
||||||
|
private readonly string _connectionName;
|
||||||
|
private readonly string _siteId;
|
||||||
|
private readonly string _instanceName;
|
||||||
|
private readonly string? _sourceScript;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
|
public AuditingDbConnection(
|
||||||
|
DbConnection inner,
|
||||||
|
IAuditWriter auditWriter,
|
||||||
|
string connectionName,
|
||||||
|
string siteId,
|
||||||
|
string instanceName,
|
||||||
|
string? sourceScript,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||||
|
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
|
||||||
|
_connectionName = connectionName ?? throw new ArgumentNullException(nameof(connectionName));
|
||||||
|
_siteId = siteId ?? string.Empty;
|
||||||
|
_instanceName = instanceName ?? string.Empty;
|
||||||
|
_sourceScript = sourceScript;
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectionString is settable on DbConnection — forward both halves.
|
||||||
|
public override string ConnectionString
|
||||||
|
{
|
||||||
|
// Some providers throw on get when the connection hasn't been opened
|
||||||
|
// with a string set explicitly. The wrapper has no opinion — forward.
|
||||||
|
#pragma warning disable CS8765 // nullability of overridden member parameter — base setter accepts null in practice
|
||||||
|
get => _inner.ConnectionString;
|
||||||
|
set => _inner.ConnectionString = value;
|
||||||
|
#pragma warning restore CS8765
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string Database => _inner.Database;
|
||||||
|
public override string DataSource => _inner.DataSource;
|
||||||
|
public override string ServerVersion => _inner.ServerVersion;
|
||||||
|
public override ConnectionState State => _inner.State;
|
||||||
|
|
||||||
|
public override void ChangeDatabase(string databaseName) => _inner.ChangeDatabase(databaseName);
|
||||||
|
public override void Close() => _inner.Close();
|
||||||
|
public override void Open() => _inner.Open();
|
||||||
|
public override Task OpenAsync(CancellationToken cancellationToken) => _inner.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
|
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
|
||||||
|
=> _inner.BeginTransaction(isolationLevel);
|
||||||
|
|
||||||
|
protected override DbCommand CreateDbCommand()
|
||||||
|
{
|
||||||
|
var innerCmd = _inner.CreateCommand();
|
||||||
|
// Hand the script an auditing wrapper. The wrapper preserves the
|
||||||
|
// inner command's identity for parameters / type maps via delegation.
|
||||||
|
return new AuditingDbCommand(
|
||||||
|
innerCmd,
|
||||||
|
_auditWriter,
|
||||||
|
_connectionName,
|
||||||
|
_siteId,
|
||||||
|
_instanceName,
|
||||||
|
_sourceScript,
|
||||||
|
_logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
_inner.Dispose();
|
||||||
|
}
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
// DbConnection.DisposeAsync is virtual; calling base would run the
|
||||||
|
// synchronous Dispose path. Forward to the inner connection
|
||||||
|
// asynchronously and short-circuit the base.
|
||||||
|
var task = _inner.DisposeAsync();
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
}
|
||||||
157
src/ScadaLink.SiteRuntime/Scripts/AuditingDbDataReader.cs
Normal file
157
src/ScadaLink.SiteRuntime/Scripts/AuditingDbDataReader.cs
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Data.Common;
|
||||||
|
|
||||||
|
namespace ScadaLink.SiteRuntime.Scripts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 — M4 Bundle A: <see cref="DbDataReader"/> decorator that
|
||||||
|
/// counts the number of rows read by the script and fires a single audit
|
||||||
|
/// emission callback when the reader closes.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The wrapping reader counts each successful <see cref="Read"/> /
|
||||||
|
/// <see cref="ReadAsync(CancellationToken)"/> and invokes <c>onClose</c>
|
||||||
|
/// exactly once — on <see cref="Close"/>, <see cref="CloseAsync"/>, or
|
||||||
|
/// disposal — with the running tally. This lets
|
||||||
|
/// <see cref="AuditingDbCommand"/> emit one
|
||||||
|
/// <c>DbOutbound</c>/<c>DbWrite</c> row per <c>ExecuteReader</c> with
|
||||||
|
/// <c>Extra.rowsReturned</c> populated, matching the M4 vocabulary lock.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Multiple result sets via <see cref="NextResult"/> are folded into a single
|
||||||
|
/// <c>rowsReturned</c> tally — the script sees one audit row per
|
||||||
|
/// <c>ExecuteReader</c> call, not per result set.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class AuditingDbDataReader : DbDataReader
|
||||||
|
{
|
||||||
|
private readonly DbDataReader _inner;
|
||||||
|
private readonly Action<int> _onClose;
|
||||||
|
private int _rowsReturned;
|
||||||
|
private bool _closed;
|
||||||
|
|
||||||
|
public AuditingDbDataReader(DbDataReader inner, Action<int> onClose)
|
||||||
|
{
|
||||||
|
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||||
|
_onClose = onClose ?? throw new ArgumentNullException(nameof(onClose));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Row-count interception ------------------------------------------
|
||||||
|
|
||||||
|
public override bool Read()
|
||||||
|
{
|
||||||
|
var more = _inner.Read();
|
||||||
|
if (more) _rowsReturned++;
|
||||||
|
return more;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<bool> ReadAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var more = await _inner.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (more) _rowsReturned++;
|
||||||
|
return more;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Close()
|
||||||
|
{
|
||||||
|
if (!_closed)
|
||||||
|
{
|
||||||
|
_closed = true;
|
||||||
|
try { _inner.Close(); }
|
||||||
|
finally { SafeFireOnClose(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task CloseAsync()
|
||||||
|
{
|
||||||
|
if (!_closed)
|
||||||
|
{
|
||||||
|
_closed = true;
|
||||||
|
try { await _inner.CloseAsync().ConfigureAwait(false); }
|
||||||
|
finally { SafeFireOnClose(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
// DbDataReader.Dispose calls Close on most providers, but we
|
||||||
|
// guard with _closed to ensure onClose fires exactly once.
|
||||||
|
if (!_closed)
|
||||||
|
{
|
||||||
|
_closed = true;
|
||||||
|
try { _inner.Dispose(); }
|
||||||
|
finally { SafeFireOnClose(); }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_inner.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (!_closed)
|
||||||
|
{
|
||||||
|
_closed = true;
|
||||||
|
try { await _inner.DisposeAsync().ConfigureAwait(false); }
|
||||||
|
finally { SafeFireOnClose(); }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _inner.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SafeFireOnClose()
|
||||||
|
{
|
||||||
|
// The onClose callback runs the audit emission, which is itself
|
||||||
|
// best-effort and swallows internally — but defend the reader's own
|
||||||
|
// close path anyway so an audit fault never propagates out of
|
||||||
|
// Close/Dispose.
|
||||||
|
try { _onClose(_rowsReturned); }
|
||||||
|
catch { /* audit emission is best-effort by contract */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Forwarded surface ------------------------------------------------
|
||||||
|
|
||||||
|
public override object this[int ordinal] => _inner[ordinal];
|
||||||
|
public override object this[string name] => _inner[name];
|
||||||
|
public override int Depth => _inner.Depth;
|
||||||
|
public override int FieldCount => _inner.FieldCount;
|
||||||
|
public override bool HasRows => _inner.HasRows;
|
||||||
|
public override bool IsClosed => _inner.IsClosed;
|
||||||
|
public override int RecordsAffected => _inner.RecordsAffected;
|
||||||
|
public override int VisibleFieldCount => _inner.VisibleFieldCount;
|
||||||
|
public override bool GetBoolean(int ordinal) => _inner.GetBoolean(ordinal);
|
||||||
|
public override byte GetByte(int ordinal) => _inner.GetByte(ordinal);
|
||||||
|
public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length)
|
||||||
|
=> _inner.GetBytes(ordinal, dataOffset, buffer, bufferOffset, length);
|
||||||
|
public override char GetChar(int ordinal) => _inner.GetChar(ordinal);
|
||||||
|
public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length)
|
||||||
|
=> _inner.GetChars(ordinal, dataOffset, buffer, bufferOffset, length);
|
||||||
|
public override string GetDataTypeName(int ordinal) => _inner.GetDataTypeName(ordinal);
|
||||||
|
public override DateTime GetDateTime(int ordinal) => _inner.GetDateTime(ordinal);
|
||||||
|
public override decimal GetDecimal(int ordinal) => _inner.GetDecimal(ordinal);
|
||||||
|
public override double GetDouble(int ordinal) => _inner.GetDouble(ordinal);
|
||||||
|
public override IEnumerator GetEnumerator() => ((IEnumerable)_inner).GetEnumerator();
|
||||||
|
public override Type GetFieldType(int ordinal) => _inner.GetFieldType(ordinal);
|
||||||
|
public override float GetFloat(int ordinal) => _inner.GetFloat(ordinal);
|
||||||
|
public override Guid GetGuid(int ordinal) => _inner.GetGuid(ordinal);
|
||||||
|
public override short GetInt16(int ordinal) => _inner.GetInt16(ordinal);
|
||||||
|
public override int GetInt32(int ordinal) => _inner.GetInt32(ordinal);
|
||||||
|
public override long GetInt64(int ordinal) => _inner.GetInt64(ordinal);
|
||||||
|
public override string GetName(int ordinal) => _inner.GetName(ordinal);
|
||||||
|
public override int GetOrdinal(string name) => _inner.GetOrdinal(name);
|
||||||
|
public override string GetString(int ordinal) => _inner.GetString(ordinal);
|
||||||
|
public override object GetValue(int ordinal) => _inner.GetValue(ordinal);
|
||||||
|
public override int GetValues(object[] values) => _inner.GetValues(values);
|
||||||
|
public override bool IsDBNull(int ordinal) => _inner.IsDBNull(ordinal);
|
||||||
|
public override bool NextResult() => _inner.NextResult();
|
||||||
|
public override Task<bool> NextResultAsync(CancellationToken cancellationToken) => _inner.NextResultAsync(cancellationToken);
|
||||||
|
}
|
||||||
@@ -252,7 +252,16 @@ public class ScriptRuntimeContext
|
|||||||
/// Database.CachedWrite("name", "sql", params)
|
/// Database.CachedWrite("name", "sql", params)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DatabaseHelper Database => new(
|
public DatabaseHelper Database => new(
|
||||||
_databaseGateway, _instanceName, _logger, _siteId, _sourceScript,
|
_databaseGateway,
|
||||||
|
_instanceName,
|
||||||
|
_logger,
|
||||||
|
// Audit Log #23 (M4 Bundle A): wire the IAuditWriter so
|
||||||
|
// Database.Connection(name) returns an auditing decorator that
|
||||||
|
// emits one DbOutbound/DbWrite row per script-initiated
|
||||||
|
// Execute / ExecuteScalar / ExecuteReader.
|
||||||
|
_auditWriter,
|
||||||
|
_siteId,
|
||||||
|
_sourceScript,
|
||||||
// Audit Log #23 (M3 Bundle E — Task E6): emit CachedSubmit telemetry on
|
// Audit Log #23 (M3 Bundle E — Task E6): emit CachedSubmit telemetry on
|
||||||
// every Database.CachedWrite enqueue.
|
// every Database.CachedWrite enqueue.
|
||||||
_cachedForwarder);
|
_cachedForwarder);
|
||||||
@@ -263,8 +272,16 @@ public class ScriptRuntimeContext
|
|||||||
/// for central delivery and returns its <c>NotificationId</c>;
|
/// for central delivery and returns its <c>NotificationId</c>;
|
||||||
/// <c>Notify.Status(id)</c> queries the delivery status of that notification.
|
/// <c>Notify.Status(id)</c> queries the delivery status of that notification.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Audit Log #23 (M4 Bundle C): the <see cref="IAuditWriter"/> is threaded
|
||||||
|
/// through so <c>Notify.To(list).Send(...)</c> emits one
|
||||||
|
/// <c>Notification</c>/<c>NotifySend</c> audit row per accepted submission.
|
||||||
|
/// Best-effort per alog.md §7 — a thrown writer never aborts the script's
|
||||||
|
/// <c>Send</c>.
|
||||||
|
/// </remarks>
|
||||||
public NotifyHelper Notify => new(
|
public NotifyHelper Notify => new(
|
||||||
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger);
|
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger,
|
||||||
|
_auditWriter);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Audit Log #23 (M3): site-local tracking-status API for cached operations.
|
/// Audit Log #23 (M3): site-local tracking-status API for cached operations.
|
||||||
@@ -894,10 +911,23 @@ public class ScriptRuntimeContext
|
|||||||
private readonly string? _sourceScript;
|
private readonly string? _sourceScript;
|
||||||
private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
|
private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (M4 Bundle A): best-effort emitter for synchronous
|
||||||
|
/// <c>Database.Connection</c>-routed Execute / ExecuteScalar /
|
||||||
|
/// ExecuteReader calls. When wired, <see cref="Connection"/> returns
|
||||||
|
/// an <see cref="AuditingDbConnection"/> that intercepts each command
|
||||||
|
/// execution and writes one <c>DbOutbound</c>/<c>DbWrite</c> audit
|
||||||
|
/// row. Optional — when null the helper falls back to the raw
|
||||||
|
/// inner <see cref="System.Data.Common.DbConnection"/> the gateway
|
||||||
|
/// returns (tests / minimal hosts that don't wire audit).
|
||||||
|
/// </summary>
|
||||||
|
private readonly IAuditWriter? _auditWriter;
|
||||||
|
|
||||||
internal DatabaseHelper(
|
internal DatabaseHelper(
|
||||||
IDatabaseGateway? gateway,
|
IDatabaseGateway? gateway,
|
||||||
string instanceName,
|
string instanceName,
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
|
IAuditWriter? auditWriter = null,
|
||||||
string siteId = "",
|
string siteId = "",
|
||||||
string? sourceScript = null,
|
string? sourceScript = null,
|
||||||
ICachedCallTelemetryForwarder? cachedForwarder = null)
|
ICachedCallTelemetryForwarder? cachedForwarder = null)
|
||||||
@@ -905,6 +935,7 @@ public class ScriptRuntimeContext
|
|||||||
_gateway = gateway;
|
_gateway = gateway;
|
||||||
_instanceName = instanceName;
|
_instanceName = instanceName;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_auditWriter = auditWriter;
|
||||||
_siteId = siteId;
|
_siteId = siteId;
|
||||||
_sourceScript = sourceScript;
|
_sourceScript = sourceScript;
|
||||||
_cachedForwarder = cachedForwarder;
|
_cachedForwarder = cachedForwarder;
|
||||||
@@ -917,7 +948,28 @@ public class ScriptRuntimeContext
|
|||||||
if (_gateway == null)
|
if (_gateway == null)
|
||||||
throw new InvalidOperationException("Database gateway not available");
|
throw new InvalidOperationException("Database gateway not available");
|
||||||
|
|
||||||
return await _gateway.GetConnectionAsync(name, cancellationToken);
|
var inner = await _gateway.GetConnectionAsync(name, cancellationToken);
|
||||||
|
|
||||||
|
// Audit Log #23 (M4 Bundle A): wrap in an auditing decorator so
|
||||||
|
// every script-initiated Execute* / ExecuteReader on the returned
|
||||||
|
// connection emits one DbOutbound/DbWrite audit row. The wrapper
|
||||||
|
// delegates all other ADO.NET behaviour to the inner connection
|
||||||
|
// unchanged — including disposal, so the caller's existing
|
||||||
|
// dispose pattern (await using var conn = ...) still releases
|
||||||
|
// the underlying connection to the pool.
|
||||||
|
if (_auditWriter == null)
|
||||||
|
{
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AuditingDbConnection(
|
||||||
|
inner,
|
||||||
|
_auditWriter,
|
||||||
|
connectionName: name,
|
||||||
|
siteId: _siteId,
|
||||||
|
instanceName: _instanceName,
|
||||||
|
sourceScript: _sourceScript,
|
||||||
|
logger: _logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1046,6 +1098,16 @@ public class ScriptRuntimeContext
|
|||||||
private readonly TimeSpan _askTimeout;
|
private readonly TimeSpan _askTimeout;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the
|
||||||
|
/// <c>Notification</c>/<c>NotifySend</c> row produced when the script
|
||||||
|
/// calls <c>Notify.To(list).Send(...)</c>. Optional — when null the
|
||||||
|
/// <see cref="NotifyTarget"/> degrades to a no-op audit path so tests
|
||||||
|
/// / minimal hosts that don't wire AddAuditLog still work (mirrors the
|
||||||
|
/// M2 Bundle F <c>IExternalSystemClient</c> wrapper).
|
||||||
|
/// </summary>
|
||||||
|
private readonly IAuditWriter? _auditWriter;
|
||||||
|
|
||||||
internal NotifyHelper(
|
internal NotifyHelper(
|
||||||
StoreAndForwardService? storeAndForward,
|
StoreAndForwardService? storeAndForward,
|
||||||
ICanTell? siteCommunicationActor,
|
ICanTell? siteCommunicationActor,
|
||||||
@@ -1053,7 +1115,8 @@ public class ScriptRuntimeContext
|
|||||||
string instanceName,
|
string instanceName,
|
||||||
string? sourceScript,
|
string? sourceScript,
|
||||||
TimeSpan askTimeout,
|
TimeSpan askTimeout,
|
||||||
ILogger logger)
|
ILogger logger,
|
||||||
|
IAuditWriter? auditWriter = null)
|
||||||
{
|
{
|
||||||
_storeAndForward = storeAndForward;
|
_storeAndForward = storeAndForward;
|
||||||
_siteCommunicationActor = siteCommunicationActor;
|
_siteCommunicationActor = siteCommunicationActor;
|
||||||
@@ -1062,6 +1125,7 @@ public class ScriptRuntimeContext
|
|||||||
_sourceScript = sourceScript;
|
_sourceScript = sourceScript;
|
||||||
_askTimeout = askTimeout;
|
_askTimeout = askTimeout;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_auditWriter = auditWriter;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1070,7 +1134,10 @@ public class ScriptRuntimeContext
|
|||||||
public NotifyTarget To(string listName)
|
public NotifyTarget To(string listName)
|
||||||
{
|
{
|
||||||
return new NotifyTarget(
|
return new NotifyTarget(
|
||||||
listName, _storeAndForward, _siteId, _instanceName, _sourceScript, _logger);
|
listName, _storeAndForward, _siteId, _instanceName, _sourceScript, _logger,
|
||||||
|
// Audit Log #23 (M4 Bundle C): forward the writer so Send()
|
||||||
|
// can emit one NotifySend(Submitted) row per accepted submission.
|
||||||
|
_auditWriter);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1145,13 +1212,22 @@ public class ScriptRuntimeContext
|
|||||||
private readonly string? _sourceScript;
|
private readonly string? _sourceScript;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the
|
||||||
|
/// <c>Notification</c>/<c>NotifySend</c> row written immediately after
|
||||||
|
/// the underlying S&F enqueue accepts the submission. Optional —
|
||||||
|
/// when null no audit row is emitted (no-op path).
|
||||||
|
/// </summary>
|
||||||
|
private readonly IAuditWriter? _auditWriter;
|
||||||
|
|
||||||
internal NotifyTarget(
|
internal NotifyTarget(
|
||||||
string listName,
|
string listName,
|
||||||
StoreAndForwardService? storeAndForward,
|
StoreAndForwardService? storeAndForward,
|
||||||
string siteId,
|
string siteId,
|
||||||
string instanceName,
|
string instanceName,
|
||||||
string? sourceScript,
|
string? sourceScript,
|
||||||
ILogger logger)
|
ILogger logger,
|
||||||
|
IAuditWriter? auditWriter = null)
|
||||||
{
|
{
|
||||||
_listName = listName;
|
_listName = listName;
|
||||||
_storeAndForward = storeAndForward;
|
_storeAndForward = storeAndForward;
|
||||||
@@ -1159,6 +1235,7 @@ public class ScriptRuntimeContext
|
|||||||
_instanceName = instanceName;
|
_instanceName = instanceName;
|
||||||
_sourceScript = sourceScript;
|
_sourceScript = sourceScript;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_auditWriter = auditWriter;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1207,6 +1284,7 @@ public class ScriptRuntimeContext
|
|||||||
// The S&F engine assigns its own GUID to the message; pin the message id to
|
// The S&F engine assigns its own GUID to the message; pin the message id to
|
||||||
// the NotificationId so the buffer can be queried by it (Notify.Status) and
|
// the NotificationId so the buffer can be queried by it (Notify.Status) and
|
||||||
// the forwarder's idempotency key matches the buffered row.
|
// the forwarder's idempotency key matches the buffered row.
|
||||||
|
var occurredAtUtc = DateTime.UtcNow;
|
||||||
await _storeAndForward.EnqueueAsync(
|
await _storeAndForward.EnqueueAsync(
|
||||||
StoreAndForwardCategory.Notification,
|
StoreAndForwardCategory.Notification,
|
||||||
target: _listName,
|
target: _listName,
|
||||||
@@ -1218,8 +1296,125 @@ public class ScriptRuntimeContext
|
|||||||
"Notify enqueued notification {NotificationId} to list '{List}' for central delivery",
|
"Notify enqueued notification {NotificationId} to list '{List}' for central delivery",
|
||||||
notificationId, _listName);
|
notificationId, _listName);
|
||||||
|
|
||||||
|
// Audit Log #23 (M4 Bundle C): emit one Notification/NotifySend
|
||||||
|
// (Submitted) row per accepted submission. The emission is wired
|
||||||
|
// AFTER the EnqueueAsync returns so we only audit submissions the
|
||||||
|
// S&F engine accepted — a failed enqueue throws, never produces an
|
||||||
|
// audit row (mirrors ESG: audit fires after the boundary call
|
||||||
|
// returned a result, never speculatively). Best-effort per alog.md
|
||||||
|
// §7 — the audit write is wrapped in try/catch and any failure is
|
||||||
|
// logged + swallowed so the script's Send call still returns the
|
||||||
|
// NotificationId.
|
||||||
|
EmitNotifySendAudit(notificationId, subject, message, occurredAtUtc);
|
||||||
|
|
||||||
return notificationId;
|
return notificationId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort emission of one <c>Notification</c>/<c>NotifySend</c>
|
||||||
|
/// (Status <c>Submitted</c>) audit row. Any exception thrown by the
|
||||||
|
/// writer is logged and swallowed — audit-write failures must never
|
||||||
|
/// abort the user-facing <c>Notify.Send</c> call (alog.md §7).
|
||||||
|
/// </summary>
|
||||||
|
private void EmitNotifySendAudit(
|
||||||
|
string notificationId,
|
||||||
|
string subject,
|
||||||
|
string body,
|
||||||
|
DateTime occurredAtUtc)
|
||||||
|
{
|
||||||
|
if (_auditWriter == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditEvent evt;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// CorrelationId is the NotificationId parsed as a Guid. Notify
|
||||||
|
// mints the id via Guid.NewGuid().ToString("N") so the parse
|
||||||
|
// is expected to succeed; on the off-chance the format
|
||||||
|
// changes / a caller injects an unparseable value, leave it
|
||||||
|
// null per Bundle B's pattern rather than fail the emission.
|
||||||
|
Guid? correlationId = Guid.TryParse(notificationId, out var parsed) ? parsed : (Guid?)null;
|
||||||
|
|
||||||
|
// M4 captures the request summary verbatim — {"subject": "...", "body": "..."}.
|
||||||
|
// M5 will layer redaction / payload-cap enforcement on top.
|
||||||
|
var requestSummary = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
subject = subject,
|
||||||
|
body = body,
|
||||||
|
});
|
||||||
|
|
||||||
|
evt = new AuditEvent
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
||||||
|
Channel = AuditChannel.Notification,
|
||||||
|
Kind = AuditKind.NotifySend,
|
||||||
|
CorrelationId = correlationId,
|
||||||
|
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
||||||
|
SourceInstanceId = _instanceName,
|
||||||
|
SourceScript = _sourceScript,
|
||||||
|
Actor = null,
|
||||||
|
Target = _listName,
|
||||||
|
Status = AuditStatus.Submitted,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception buildEx)
|
||||||
|
{
|
||||||
|
// Defensive: building the event itself must never propagate.
|
||||||
|
_logger.LogWarning(buildEx,
|
||||||
|
"Failed to build Audit Log #23 NotifySend event for NotificationId {NotificationId} list '{List}' — skipping emission",
|
||||||
|
notificationId, _listName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Fire-and-forget (mirrors ExternalSystemHelper.EmitCallAudit)
|
||||||
|
// so the script is never blocked on the audit writer; we observe
|
||||||
|
// failures via ContinueWith so a thrown writer is logged rather
|
||||||
|
// than going to the unobserved-task firehose.
|
||||||
|
var writeTask = _auditWriter.WriteAsync(evt, CancellationToken.None);
|
||||||
|
if (!writeTask.IsCompleted)
|
||||||
|
{
|
||||||
|
writeTask.ContinueWith(
|
||||||
|
t => _logger.LogWarning(t.Exception,
|
||||||
|
"Audit Log #23 write failed for EventId {EventId} (NotifySend NotificationId {NotificationId})",
|
||||||
|
evt.EventId, notificationId),
|
||||||
|
CancellationToken.None,
|
||||||
|
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||||
|
TaskScheduler.Default);
|
||||||
|
}
|
||||||
|
else if (writeTask.IsFaulted)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(writeTask.Exception,
|
||||||
|
"Audit Log #23 write failed for EventId {EventId} (NotifySend NotificationId {NotificationId})",
|
||||||
|
evt.EventId, notificationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception writeEx)
|
||||||
|
{
|
||||||
|
// Synchronous throw from WriteAsync (e.g. ArgumentNullException
|
||||||
|
// before the writer's own try/catch). Swallow + log per alog.md §7.
|
||||||
|
_logger.LogWarning(writeEx,
|
||||||
|
"Audit Log #23 write threw synchronously for EventId {EventId} (NotifySend NotificationId {NotificationId})",
|
||||||
|
evt.EventId, notificationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ScadaLink.AuditLog.Central;
|
||||||
using ScadaLink.AuditLog.Configuration;
|
using ScadaLink.AuditLog.Configuration;
|
||||||
using ScadaLink.AuditLog.Site;
|
using ScadaLink.AuditLog.Site;
|
||||||
using ScadaLink.AuditLog.Site.Telemetry;
|
using ScadaLink.AuditLog.Site.Telemetry;
|
||||||
@@ -155,6 +156,34 @@ public class AddAuditLogTests
|
|||||||
Assert.IsType<NoOpSiteStreamAuditClient>(client);
|
Assert.IsType<NoOpSiteStreamAuditClient>(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- M4 Bundle B (B1) central direct-write audit writer -----------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddAuditLog_Registers_ICentralAuditWriter_AsCentralAuditWriter()
|
||||||
|
{
|
||||||
|
using var provider = BuildProvider(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
|
||||||
|
});
|
||||||
|
|
||||||
|
var writer = provider.GetService<ICentralAuditWriter>();
|
||||||
|
Assert.NotNull(writer);
|
||||||
|
Assert.IsType<CentralAuditWriter>(writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddAuditLog_ICentralAuditWriter_IsSingleton()
|
||||||
|
{
|
||||||
|
using var provider = BuildProvider(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
|
||||||
|
});
|
||||||
|
|
||||||
|
var w1 = provider.GetService<ICentralAuditWriter>();
|
||||||
|
var w2 = provider.GetService<ICentralAuditWriter>();
|
||||||
|
Assert.Same(w1, w2);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddAuditLog_Options_Bind_RoundTrip_SqliteWriter()
|
public void AddAuditLog_Options_Bind_RoundTrip_SqliteWriter()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NSubstitute;
|
||||||
|
using NSubstitute.ExceptionExtensions;
|
||||||
|
using ScadaLink.AuditLog.Central;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.AuditLog.Tests.Central;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M4 Bundle B1 — unit tests for <see cref="CentralAuditWriter"/>, the
|
||||||
|
/// central-only direct-write implementation of <see cref="ICentralAuditWriter"/>.
|
||||||
|
/// The writer is a thin wrapper around
|
||||||
|
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/>: it stamps
|
||||||
|
/// <see cref="AuditEvent.IngestedAtUtc"/>, resolves the (scoped) repository
|
||||||
|
/// from a fresh DI scope per call, and swallows any thrown exception —
|
||||||
|
/// audit-write failures NEVER abort the user-facing action (alog.md §13).
|
||||||
|
/// </summary>
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriter()
|
||||||
|
{
|
||||||
|
var repo = Substitute.For<IAuditLogRepository>();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddScoped(_ => repo);
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
return (new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance), repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteAsync_PassesEvent_To_InsertIfNotExistsAsync()
|
||||||
|
{
|
||||||
|
var (writer, repo) = BuildWriter();
|
||||||
|
var evt = NewEvent();
|
||||||
|
|
||||||
|
await writer.WriteAsync(evt);
|
||||||
|
|
||||||
|
await repo.Received(1).InsertIfNotExistsAsync(
|
||||||
|
Arg.Is<AuditEvent>(e => e.EventId == evt.EventId),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteAsync_Stamps_IngestedAtUtc_Before_Insert()
|
||||||
|
{
|
||||||
|
var (writer, repo) = BuildWriter();
|
||||||
|
var before = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await writer.WriteAsync(NewEvent());
|
||||||
|
|
||||||
|
var after = DateTime.UtcNow;
|
||||||
|
await repo.Received(1).InsertIfNotExistsAsync(
|
||||||
|
Arg.Is<AuditEvent>(e =>
|
||||||
|
e.IngestedAtUtc != null &&
|
||||||
|
e.IngestedAtUtc >= before &&
|
||||||
|
e.IngestedAtUtc <= after),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteAsync_Repository_Throws_DoesNotPropagate()
|
||||||
|
{
|
||||||
|
var repo = Substitute.For<IAuditLogRepository>();
|
||||||
|
repo.InsertIfNotExistsAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new InvalidOperationException("db down"));
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddScoped(_ => repo);
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
var writer = new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance);
|
||||||
|
|
||||||
|
// Must not throw — audit failure NEVER aborts the user-facing action.
|
||||||
|
await writer.WriteAsync(NewEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteAsync_Resolves_Repository_PerCall_From_Fresh_Scope()
|
||||||
|
{
|
||||||
|
// Counting factory: every scope opening should resolve a new repo
|
||||||
|
// (scoped lifetime). We assert at least two distinct instances
|
||||||
|
// across two WriteAsync calls.
|
||||||
|
var instances = new List<IAuditLogRepository>();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddScoped<IAuditLogRepository>(_ =>
|
||||||
|
{
|
||||||
|
var r = Substitute.For<IAuditLogRepository>();
|
||||||
|
instances.Add(r);
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
var writer = new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance);
|
||||||
|
|
||||||
|
await writer.WriteAsync(NewEvent());
|
||||||
|
await writer.WriteAsync(NewEvent());
|
||||||
|
|
||||||
|
Assert.Equal(2, instances.Count);
|
||||||
|
Assert.NotSame(instances[0], instances[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_NullServices_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentNullException>(
|
||||||
|
() => new CentralAuditWriter(null!, NullLogger<CentralAuditWriter>.Instance));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_NullLogger_Throws()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection().BuildServiceProvider();
|
||||||
|
Assert.Throws<ArgumentNullException>(
|
||||||
|
() => new CentralAuditWriter(services, null!));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NSubstitute;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Entities.Notifications;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.Commons.Messages.Integration;
|
||||||
|
using ScadaLink.Commons.Types;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.ConfigurationDatabase;
|
||||||
|
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||||
|
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
|
||||||
|
using ScadaLink.InboundAPI.Middleware;
|
||||||
|
using ScadaLink.NotificationOutbox;
|
||||||
|
using ScadaLink.NotificationOutbox.Delivery;
|
||||||
|
using ScadaLink.NotificationOutbox.Messages;
|
||||||
|
using ScadaLink.SiteRuntime.Scripts;
|
||||||
|
using System.Net;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
|
||||||
|
namespace ScadaLink.AuditLog.Tests.Integration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 — M4 Bundle E (Task E4) cross-boundary safety suite verifying
|
||||||
|
/// the alog.md §13 contract: an always-throwing audit writer NEVER aborts the
|
||||||
|
/// user-facing action. Exercises every boundary that emits audit rows in M2,
|
||||||
|
/// M3, and M4:
|
||||||
|
///
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>External system sync call (M2 Bundle F).</description></item>
|
||||||
|
/// <item><description>External system cached call (M3 Bundle E).</description></item>
|
||||||
|
/// <item><description>Database sync write (M4 Bundle A).</description></item>
|
||||||
|
/// <item><description>Inbound API request (M4 Bundle D).</description></item>
|
||||||
|
/// <item><description>Notification dispatcher (M4 Bundle B).</description></item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The site-local boundaries (ESG sync/cached, DB sync) take the always-throw
|
||||||
|
/// <see cref="ThrowingAuditWriter"/> in place of the production
|
||||||
|
/// <see cref="IAuditWriter"/>; the central boundaries (Inbound API,
|
||||||
|
/// Notification dispatcher) take the always-throw
|
||||||
|
/// <see cref="ThrowingCentralAuditWriter"/> in place of
|
||||||
|
/// <see cref="ICentralAuditWriter"/>. In each case the wrapped action's
|
||||||
|
/// original return value (or original exception) must still flow back to the
|
||||||
|
/// caller untouched.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
||||||
|
{
|
||||||
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
public AuditWriteFailureSafetyTests(MsSqlMigrationFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Always-throwing writer test doubles
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Site-side <see cref="IAuditWriter"/> that ALWAYS throws on
|
||||||
|
/// <see cref="WriteAsync"/>. Used to verify that ESG / DB script-side
|
||||||
|
/// helpers swallow the throw and return their normal result to the script.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ThrowingAuditWriter : IAuditWriter
|
||||||
|
{
|
||||||
|
private int _attempts;
|
||||||
|
public int Attempts => Volatile.Read(ref _attempts);
|
||||||
|
|
||||||
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _attempts);
|
||||||
|
return Task.FromException(new InvalidOperationException(
|
||||||
|
"test-only ThrowingAuditWriter — audit pipeline unavailable"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central-side <see cref="ICentralAuditWriter"/> that ALWAYS throws on
|
||||||
|
/// <see cref="WriteAsync"/>. Used to verify Inbound API + Notification
|
||||||
|
/// dispatcher absorb audit-write failures rather than propagating them
|
||||||
|
/// into the response / state transition.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ThrowingCentralAuditWriter : ICentralAuditWriter
|
||||||
|
{
|
||||||
|
private int _attempts;
|
||||||
|
public int Attempts => Volatile.Read(ref _attempts);
|
||||||
|
|
||||||
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _attempts);
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"test-only ThrowingCentralAuditWriter — audit subsystem unavailable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Site-side <see cref="ICachedCallTelemetryForwarder"/> that ALWAYS
|
||||||
|
/// throws on <see cref="ForwardAsync"/>. The cached-call helpers absorb
|
||||||
|
/// the throw and still return a valid <see cref="TrackedOperationId"/>.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ThrowingCachedForwarder : ICachedCallTelemetryForwarder
|
||||||
|
{
|
||||||
|
private int _attempts;
|
||||||
|
public int Attempts => Volatile.Read(ref _attempts);
|
||||||
|
|
||||||
|
public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _attempts);
|
||||||
|
return Task.FromException(new InvalidOperationException(
|
||||||
|
"test-only ThrowingCachedForwarder — telemetry pipeline unavailable"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Test 1 — ESG sync call still returns the original ExternalCallResult.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EsgSyncCall_BrokenAuditWriter_StillReturnsResult()
|
||||||
|
{
|
||||||
|
var client = Substitute.For<IExternalSystemClient>();
|
||||||
|
var expected = new ExternalCallResult(
|
||||||
|
Success: true,
|
||||||
|
ResponseJson: "{\"orderId\":42}",
|
||||||
|
ErrorMessage: null,
|
||||||
|
WasBuffered: false);
|
||||||
|
client.CallAsync(
|
||||||
|
"ERP", "GetOrder",
|
||||||
|
Arg.Any<IReadOnlyDictionary<string, object?>?>(),
|
||||||
|
Arg.Any<CancellationToken>())
|
||||||
|
.Returns(expected);
|
||||||
|
|
||||||
|
var writer = new ThrowingAuditWriter();
|
||||||
|
var helper = new ScriptRuntimeContext.ExternalSystemHelper(
|
||||||
|
client,
|
||||||
|
instanceName: "Plant.Pump42",
|
||||||
|
NullLogger.Instance,
|
||||||
|
auditWriter: writer,
|
||||||
|
siteId: "site-77",
|
||||||
|
sourceScript: "ScriptActor:Sync",
|
||||||
|
cachedForwarder: null);
|
||||||
|
|
||||||
|
var result = await helper.Call("ERP", "GetOrder");
|
||||||
|
|
||||||
|
Assert.Same(expected, result);
|
||||||
|
// Proof the audit writer was attempted — otherwise the test wouldn't
|
||||||
|
// actually exercise the safety contract.
|
||||||
|
Assert.True(writer.Attempts >= 1,
|
||||||
|
$"Expected audit writer to be invoked at least once; saw {writer.Attempts}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Test 2 — ESG cached call still returns a TrackedOperationId.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EsgCachedCall_BrokenAuditWriter_StillReturnsTrackedOperationId()
|
||||||
|
{
|
||||||
|
var client = Substitute.For<IExternalSystemClient>();
|
||||||
|
// CachedCallAsync returns WasBuffered=true so the helper takes the
|
||||||
|
// S&F-deferred path — no immediate-terminal telemetry, which keeps the
|
||||||
|
// forwarder attempt count at exactly one (the CachedSubmit emission).
|
||||||
|
client.CachedCallAsync(
|
||||||
|
"ERP", "GetOrder",
|
||||||
|
Arg.Any<IReadOnlyDictionary<string, object?>?>(),
|
||||||
|
Arg.Any<string?>(),
|
||||||
|
Arg.Any<CancellationToken>(),
|
||||||
|
Arg.Any<TrackedOperationId?>())
|
||||||
|
.Returns(new ExternalCallResult(true, null, null, WasBuffered: true));
|
||||||
|
|
||||||
|
// BOTH the audit writer AND the cached forwarder throw — the
|
||||||
|
// CachedSubmit emission goes through the forwarder in production, so
|
||||||
|
// breaking only the writer wouldn't actually exercise the cached
|
||||||
|
// path's safety contract.
|
||||||
|
var writer = new ThrowingAuditWriter();
|
||||||
|
var forwarder = new ThrowingCachedForwarder();
|
||||||
|
var helper = new ScriptRuntimeContext.ExternalSystemHelper(
|
||||||
|
client,
|
||||||
|
instanceName: "Plant.Pump42",
|
||||||
|
NullLogger.Instance,
|
||||||
|
auditWriter: writer,
|
||||||
|
siteId: "site-77",
|
||||||
|
sourceScript: "ScriptActor:Cached",
|
||||||
|
cachedForwarder: forwarder);
|
||||||
|
|
||||||
|
var trackedId = await helper.CachedCall("ERP", "GetOrder");
|
||||||
|
|
||||||
|
// Non-default id materialised despite the forwarder failing.
|
||||||
|
Assert.NotEqual(default, trackedId);
|
||||||
|
Assert.True(forwarder.Attempts >= 1,
|
||||||
|
$"Expected cached forwarder to be invoked at least once; saw {forwarder.Attempts}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Test 3 — DB sync write still returns the rows-affected count.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DbSyncWrite_BrokenAuditWriter_StillReturnsRowsAffected()
|
||||||
|
{
|
||||||
|
const string connectionName = "machineData";
|
||||||
|
const string instanceName = "Plant.Pump42";
|
||||||
|
|
||||||
|
using var keepAlive = new SqliteConnection(
|
||||||
|
"Data Source=k-safety-db;Mode=Memory;Cache=Shared");
|
||||||
|
keepAlive.Open();
|
||||||
|
|
||||||
|
// Schema + seed inside a unique in-memory DB.
|
||||||
|
var dbName = $"db-{Guid.NewGuid():N}";
|
||||||
|
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
|
||||||
|
using var dbKeepAlive = new SqliteConnection(connStr);
|
||||||
|
dbKeepAlive.Open();
|
||||||
|
using (var seed = dbKeepAlive.CreateCommand())
|
||||||
|
{
|
||||||
|
seed.CommandText =
|
||||||
|
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);";
|
||||||
|
seed.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
var inner = new SqliteConnection(connStr);
|
||||||
|
inner.Open();
|
||||||
|
|
||||||
|
var gateway = Substitute.For<IDatabaseGateway>();
|
||||||
|
gateway.GetConnectionAsync(connectionName, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(inner);
|
||||||
|
|
||||||
|
var writer = new ThrowingAuditWriter();
|
||||||
|
var helper = new ScriptRuntimeContext.DatabaseHelper(
|
||||||
|
gateway,
|
||||||
|
instanceName,
|
||||||
|
NullLogger.Instance,
|
||||||
|
auditWriter: writer,
|
||||||
|
siteId: "site-77",
|
||||||
|
sourceScript: "ScriptActor:Db",
|
||||||
|
cachedForwarder: null);
|
||||||
|
|
||||||
|
await using (var conn = await helper.Connection(connectionName))
|
||||||
|
await using (var cmd = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "INSERT INTO t (id, name) VALUES (1, 'safety')";
|
||||||
|
var rows = await cmd.ExecuteNonQueryAsync();
|
||||||
|
Assert.Equal(1, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(writer.Attempts >= 1,
|
||||||
|
$"Expected audit writer to be invoked at least once; saw {writer.Attempts}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Test 4 — Inbound API request still returns HTTP 200.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InboundApi_BrokenAuditWriter_StillReturns200()
|
||||||
|
{
|
||||||
|
var writer = new ThrowingCentralAuditWriter();
|
||||||
|
|
||||||
|
using var host = await BuildInboundApiHostAsync(writer, endpointStatus: 200);
|
||||||
|
var client = host.GetTestClient();
|
||||||
|
|
||||||
|
var resp = await client.PostAsync(
|
||||||
|
"/api/echo",
|
||||||
|
new StringContent("{\"x\":1}", Encoding.UTF8, "application/json"));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||||
|
Assert.True(writer.Attempts >= 1,
|
||||||
|
$"Expected central audit writer to be invoked at least once; saw {writer.Attempts}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Test 5 — Notification dispatcher still transitions to Delivered.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task NotificationDispatch_BrokenAuditWriter_StillTransitionsToDelivered()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = "test-e4-safety-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||||
|
var notificationId = Guid.NewGuid();
|
||||||
|
|
||||||
|
await SeedSmtpConfigAsync();
|
||||||
|
await SeedNotificationAsync(notificationId, siteId);
|
||||||
|
|
||||||
|
var adapter = new SingleOutcomeAdapter(DeliveryOutcome.Success("ops@example.com"));
|
||||||
|
var serviceProvider = BuildNotificationDispatcherProvider(adapter);
|
||||||
|
var throwingWriter = new ThrowingCentralAuditWriter();
|
||||||
|
|
||||||
|
var actor = Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||||
|
serviceProvider,
|
||||||
|
new NotificationOutboxOptions
|
||||||
|
{
|
||||||
|
DispatchInterval = TimeSpan.FromHours(1),
|
||||||
|
PurgeInterval = TimeSpan.FromDays(1),
|
||||||
|
},
|
||||||
|
(ICentralAuditWriter)throwingWriter,
|
||||||
|
NullLogger<NotificationOutboxActor>.Instance)));
|
||||||
|
|
||||||
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
|
||||||
|
// Notifications table reflects the successful delivery even though
|
||||||
|
// every audit write threw — the central direct-write writer
|
||||||
|
// catches/logs internally and the dispatcher catches defensively too
|
||||||
|
// (alog.md §13).
|
||||||
|
await AwaitAssertAsync(async () =>
|
||||||
|
{
|
||||||
|
await using var ctx = CreateContext();
|
||||||
|
var row = await ctx.Notifications.SingleAsync(
|
||||||
|
n => n.NotificationId == notificationId.ToString("D"));
|
||||||
|
Assert.Equal(NotificationStatus.Delivered, row.Status);
|
||||||
|
Assert.NotNull(row.DeliveredAt);
|
||||||
|
}, TimeSpan.FromSeconds(15));
|
||||||
|
|
||||||
|
Assert.True(throwingWriter.Attempts >= 1,
|
||||||
|
$"Expected dispatcher to attempt audit write at least once; saw {throwingWriter.Attempts}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Test infrastructure
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
private ScadaLinkDbContext CreateContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||||
|
.UseSqlServer(_fixture.ConnectionString)
|
||||||
|
.ConfigureWarnings(w => w.Ignore(
|
||||||
|
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))
|
||||||
|
.Options;
|
||||||
|
return new ScadaLinkDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IServiceProvider BuildNotificationDispatcherProvider(
|
||||||
|
INotificationDeliveryAdapter adapter)
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddDbContext<ScadaLinkDbContext>(opts =>
|
||||||
|
opts.UseSqlServer(_fixture.ConnectionString)
|
||||||
|
.ConfigureWarnings(w => w.Ignore(
|
||||||
|
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
||||||
|
services.AddScoped<INotificationOutboxRepository>(sp =>
|
||||||
|
new NotificationOutboxRepository(sp.GetRequiredService<ScadaLinkDbContext>()));
|
||||||
|
services.AddScoped<INotificationRepository>(sp =>
|
||||||
|
new NotificationRepository(sp.GetRequiredService<ScadaLinkDbContext>()));
|
||||||
|
services.AddScoped<INotificationDeliveryAdapter>(_ => adapter);
|
||||||
|
return services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeedSmtpConfigAsync()
|
||||||
|
{
|
||||||
|
await using var ctx = CreateContext();
|
||||||
|
ctx.SmtpConfigurations.Add(new SmtpConfiguration(
|
||||||
|
"smtp.example.com", "Basic", "noreply@example.com")
|
||||||
|
{
|
||||||
|
MaxRetries = 5,
|
||||||
|
RetryDelay = TimeSpan.Zero,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeedNotificationAsync(Guid notificationId, string siteId)
|
||||||
|
{
|
||||||
|
await using var ctx = CreateContext();
|
||||||
|
ctx.Notifications.Add(new Notification(
|
||||||
|
notificationId.ToString("D"),
|
||||||
|
NotificationType.Email,
|
||||||
|
"ops-team",
|
||||||
|
"Safety subject",
|
||||||
|
"Safety body",
|
||||||
|
siteId)
|
||||||
|
{
|
||||||
|
SourceInstanceId = "Plant.Pump42",
|
||||||
|
SourceScript = "AlarmScript",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single-outcome adapter — returns the same <see cref="DeliveryOutcome"/>
|
||||||
|
/// for every call. Used by the dispatcher safety test where we only need
|
||||||
|
/// one happy-path delivery.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class SingleOutcomeAdapter : INotificationDeliveryAdapter
|
||||||
|
{
|
||||||
|
private readonly DeliveryOutcome _outcome;
|
||||||
|
public SingleOutcomeAdapter(DeliveryOutcome outcome) { _outcome = outcome; }
|
||||||
|
public NotificationType Type => NotificationType.Email;
|
||||||
|
public Task<DeliveryOutcome> DeliverAsync(
|
||||||
|
Notification notification, CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(_outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an in-memory TestHost mirroring the production inbound-API
|
||||||
|
/// pipeline order. The supplied <paramref name="writer"/> stands in for
|
||||||
|
/// the production <see cref="ICentralAuditWriter"/> so the safety test can
|
||||||
|
/// install the always-throwing variant without standing up any DB.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<IHost> BuildInboundApiHostAsync(
|
||||||
|
ICentralAuditWriter writer, int endpointStatus)
|
||||||
|
{
|
||||||
|
var hostBuilder = new HostBuilder()
|
||||||
|
.ConfigureWebHost(webBuilder =>
|
||||||
|
{
|
||||||
|
webBuilder
|
||||||
|
.UseTestServer()
|
||||||
|
.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
services.AddSingleton(writer);
|
||||||
|
services.AddRouting();
|
||||||
|
services.AddAuthorization();
|
||||||
|
services.AddAuthentication("TestScheme")
|
||||||
|
.AddScheme<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions,
|
||||||
|
AlwaysAuthenticatedHandler>("TestScheme", _ => { });
|
||||||
|
})
|
||||||
|
.Configure(app =>
|
||||||
|
{
|
||||||
|
app.UseRouting();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.UseAuditWriteMiddleware();
|
||||||
|
app.UseEndpoints(endpoints =>
|
||||||
|
{
|
||||||
|
endpoints.MapPost("/api/{methodName}", async ctx =>
|
||||||
|
{
|
||||||
|
ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "safety-actor";
|
||||||
|
ctx.Response.StatusCode = endpointStatus;
|
||||||
|
await ctx.Response.WriteAsync("ok");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return await hostBuilder.StartAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AlwaysAuthenticatedHandler
|
||||||
|
: Microsoft.AspNetCore.Authentication.AuthenticationHandler<
|
||||||
|
Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions>
|
||||||
|
{
|
||||||
|
public AlwaysAuthenticatedHandler(
|
||||||
|
IOptionsMonitor<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions> options,
|
||||||
|
Microsoft.Extensions.Logging.ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder)
|
||||||
|
: base(options, logger, encoder) { }
|
||||||
|
|
||||||
|
protected override Task<Microsoft.AspNetCore.Authentication.AuthenticateResult>
|
||||||
|
HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
var identity = new ClaimsIdentity(
|
||||||
|
new[] { new Claim(ClaimTypes.Name, "framework-user") }, "TestScheme");
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
var ticket = new Microsoft.AspNetCore.Authentication.AuthenticationTicket(
|
||||||
|
principal, "TestScheme");
|
||||||
|
return Task.FromResult(
|
||||||
|
Microsoft.AspNetCore.Authentication.AuthenticateResult.Success(ticket));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NSubstitute;
|
||||||
|
using ScadaLink.AuditLog.Central;
|
||||||
|
using ScadaLink.AuditLog.Site;
|
||||||
|
using ScadaLink.AuditLog.Site.Telemetry;
|
||||||
|
using ScadaLink.AuditLog.Tests.Integration.Infrastructure;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.ConfigurationDatabase;
|
||||||
|
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||||
|
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
|
||||||
|
using ScadaLink.SiteRuntime.Scripts;
|
||||||
|
|
||||||
|
namespace ScadaLink.AuditLog.Tests.Integration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 — M4 Bundle E (Task E1) end-to-end suite verifying every
|
||||||
|
/// synchronous <c>Database.Connection(name).Execute*</c> /
|
||||||
|
/// <c>ExecuteReader</c> call made via the Bundle A
|
||||||
|
/// <see cref="ScriptRuntimeContext.DatabaseHelper"/> emits exactly one
|
||||||
|
/// <see cref="AuditChannel.DbOutbound"/>/<see cref="AuditKind.DbWrite"/> row
|
||||||
|
/// that materialises in the central MSSQL <c>AuditLog</c> via the production
|
||||||
|
/// site-SQLite + telemetry-actor + central ingest-actor pipeline.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Composes the same pipeline as the M2 <see cref="SyncCallEmissionEndToEndTests"/>:
|
||||||
|
/// in-memory <see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
|
||||||
|
/// <see cref="FallbackAuditWriter"/> on the site, drained by a real
|
||||||
|
/// <see cref="SiteAuditTelemetryActor"/> through a
|
||||||
|
/// <see cref="DirectActorSiteStreamAuditClient"/> stub that short-circuits the
|
||||||
|
/// gRPC wire and Asks the central <see cref="AuditLogIngestActor"/> backed by
|
||||||
|
/// the real <see cref="AuditLogRepository"/> on the per-class
|
||||||
|
/// <see cref="MsSqlMigrationFixture"/> MSSQL database.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Drives the AuditingDbConnection wrapper directly via
|
||||||
|
/// <see cref="ScriptRuntimeContext.DatabaseHelper"/>'s internal ctor (the
|
||||||
|
/// AuditLog tests project has <c>InternalsVisibleTo</c> on SiteRuntime). No
|
||||||
|
/// script runtime, no Akka Instance Actor — the test wires the helper, opens
|
||||||
|
/// an in-memory SQLite connection through a stub <see cref="IDatabaseGateway"/>,
|
||||||
|
/// runs one SQL statement, and waits for the central row to land. Each test
|
||||||
|
/// uses a unique <c>SourceSiteId</c> (Guid suffix) so concurrent tests
|
||||||
|
/// sharing the MSSQL fixture don't interfere with each other.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
||||||
|
{
|
||||||
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
public DatabaseSyncEmissionEndToEndTests(MsSqlMigrationFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string ConnectionName = "machineData";
|
||||||
|
private const string InstanceName = "Plant.Pump42";
|
||||||
|
private const string SourceScript = "ScriptActor:doDbWork";
|
||||||
|
|
||||||
|
private static string NewSiteId() =>
|
||||||
|
"test-e1-db-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||||
|
|
||||||
|
private ScadaLinkDbContext CreateContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||||
|
.UseSqlServer(_fixture.ConnectionString)
|
||||||
|
.Options;
|
||||||
|
return new ScadaLinkDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-test in-memory SQLite database with a tiny 2-row schema we can both
|
||||||
|
/// write to and select from. Mirrors the pattern from
|
||||||
|
/// <c>DatabaseSyncEmissionTests</c> — the keep-alive root keeps the
|
||||||
|
/// in-memory database file pinned for the duration of the test, while the
|
||||||
|
/// returned <c>live</c> connection is what the stub gateway hands back to
|
||||||
|
/// the auditing wrapper.
|
||||||
|
/// </summary>
|
||||||
|
private static SqliteConnection NewInMemoryDb(out SqliteConnection keepAlive)
|
||||||
|
{
|
||||||
|
var dbName = $"db-{Guid.NewGuid():N}";
|
||||||
|
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
|
||||||
|
|
||||||
|
keepAlive = new SqliteConnection(connStr);
|
||||||
|
keepAlive.Open();
|
||||||
|
using (var seed = keepAlive.CreateCommand())
|
||||||
|
{
|
||||||
|
seed.CommandText =
|
||||||
|
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);" +
|
||||||
|
"INSERT INTO t (id, name) VALUES (1, 'alpha');" +
|
||||||
|
"INSERT INTO t (id, name) VALUES (2, 'beta');";
|
||||||
|
seed.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
var live = new SqliteConnection(connStr);
|
||||||
|
live.Open();
|
||||||
|
return live;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SqliteAuditWriter CreateInMemorySqliteWriter() =>
|
||||||
|
new(
|
||||||
|
Options.Create(new SqliteAuditWriterOptions
|
||||||
|
{
|
||||||
|
DatabasePath = "ignored",
|
||||||
|
BatchSize = 64,
|
||||||
|
ChannelCapacity = 1024,
|
||||||
|
}),
|
||||||
|
NullLogger<SqliteAuditWriter>.Instance,
|
||||||
|
connectionStringOverride:
|
||||||
|
$"Data Source=file:auditlog-e1-{Guid.NewGuid():N}?mode=memory&cache=shared");
|
||||||
|
|
||||||
|
private static IOptions<SiteAuditTelemetryOptions> FastTelemetryOptions() =>
|
||||||
|
Options.Create(new SiteAuditTelemetryOptions
|
||||||
|
{
|
||||||
|
BatchSize = 256,
|
||||||
|
// 1s on both intervals so the initial scheduled tick fires quickly
|
||||||
|
// — drains the SQLite Pending row and pushes it through the stub
|
||||||
|
// gRPC client into the central ingest actor.
|
||||||
|
BusyIntervalSeconds = 1,
|
||||||
|
IdleIntervalSeconds = 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
private IActorRef CreateIngestActor(IAuditLogRepository repo) =>
|
||||||
|
Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
|
||||||
|
repo,
|
||||||
|
NullLogger<AuditLogIngestActor>.Instance)));
|
||||||
|
|
||||||
|
private IActorRef CreateTelemetryActor(
|
||||||
|
ISiteAuditQueue queue,
|
||||||
|
ISiteStreamAuditClient client) =>
|
||||||
|
Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor(
|
||||||
|
queue,
|
||||||
|
client,
|
||||||
|
FastTelemetryOptions(),
|
||||||
|
NullLogger<SiteAuditTelemetryActor>.Instance)));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wires the production
|
||||||
|
/// <see cref="ScriptRuntimeContext.DatabaseHelper"/> (internal ctor) onto
|
||||||
|
/// the supplied <see cref="IDatabaseGateway"/> + <see cref="IAuditWriter"/>
|
||||||
|
/// with the test's site id and source script. The returned helper's
|
||||||
|
/// <c>Connection(...)</c> hands back a real <c>AuditingDbConnection</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
|
||||||
|
IDatabaseGateway gateway,
|
||||||
|
IAuditWriter writer,
|
||||||
|
string siteId) =>
|
||||||
|
new(
|
||||||
|
gateway,
|
||||||
|
InstanceName,
|
||||||
|
NullLogger.Instance,
|
||||||
|
auditWriter: writer,
|
||||||
|
siteId: siteId,
|
||||||
|
sourceScript: SourceScript,
|
||||||
|
cachedForwarder: null);
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task DbWrite_Insert_Emits_OneCentralRow_WithExtraOpWrite_AndRowsAffected()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
|
||||||
|
// Central — repository + ingest actor backed by the MSSQL fixture.
|
||||||
|
await using var ingestContext = CreateContext();
|
||||||
|
var ingestRepo = new AuditLogRepository(ingestContext);
|
||||||
|
var ingestActor = CreateIngestActor(ingestRepo);
|
||||||
|
|
||||||
|
// Site — SQLite audit writer + ring + fallback + telemetry actor that
|
||||||
|
// drains into the stub gRPC client which forwards to the ingest actor.
|
||||||
|
await using var sqliteWriter = CreateInMemorySqliteWriter();
|
||||||
|
var ring = new RingBufferFallback();
|
||||||
|
var fallback = new FallbackAuditWriter(
|
||||||
|
sqliteWriter,
|
||||||
|
ring,
|
||||||
|
new NoOpAuditWriteFailureCounter(),
|
||||||
|
NullLogger<FallbackAuditWriter>.Instance);
|
||||||
|
var stubClient = new DirectActorSiteStreamAuditClient(ingestActor);
|
||||||
|
CreateTelemetryActor(sqliteWriter, stubClient);
|
||||||
|
|
||||||
|
// SQLite-backed inner connection — the stub gateway hands it to the
|
||||||
|
// auditing wrapper as the DbConnection the script would have got.
|
||||||
|
using var keepAlive = new SqliteConnection("Data Source=k1;Mode=Memory;Cache=Shared");
|
||||||
|
var inner = NewInMemoryDb(out _);
|
||||||
|
var gateway = Substitute.For<IDatabaseGateway>();
|
||||||
|
gateway.GetConnectionAsync(ConnectionName, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(inner);
|
||||||
|
|
||||||
|
// Act — one INSERT through the auditing wrapper. The wrapper emits a
|
||||||
|
// single DbOutbound/DbWrite event to the fallback writer; the
|
||||||
|
// telemetry actor's next tick drains it to central.
|
||||||
|
var helper = CreateHelper(gateway, fallback, siteId);
|
||||||
|
await using (var conn = await helper.Connection(ConnectionName))
|
||||||
|
await using (var cmd = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "INSERT INTO t (id, name) VALUES (3, 'gamma')";
|
||||||
|
var rows = await cmd.ExecuteNonQueryAsync();
|
||||||
|
Assert.Equal(1, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert — one central row, Kind=DbWrite, Status=Delivered,
|
||||||
|
// Extra.op="write", Extra.rowsAffected=1. 15s upper bound covers the
|
||||||
|
// initial 1s tick + SQLite drain + actor round-trip + EF/MSSQL latency.
|
||||||
|
await AwaitAssertAsync(async () =>
|
||||||
|
{
|
||||||
|
await using var readContext = CreateContext();
|
||||||
|
var readRepo = new AuditLogRepository(readContext);
|
||||||
|
var rows = await readRepo.QueryAsync(
|
||||||
|
new AuditLogQueryFilter(SourceSiteId: siteId),
|
||||||
|
new AuditLogPaging(PageSize: 10));
|
||||||
|
var evt = Assert.Single(rows);
|
||||||
|
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
|
||||||
|
Assert.Equal(AuditKind.DbWrite, evt.Kind);
|
||||||
|
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
||||||
|
Assert.Equal(siteId, evt.SourceSiteId);
|
||||||
|
Assert.Equal(InstanceName, evt.SourceInstanceId);
|
||||||
|
Assert.Equal(SourceScript, evt.SourceScript);
|
||||||
|
Assert.NotNull(evt.Extra);
|
||||||
|
Assert.Contains("\"op\":\"write\"", evt.Extra);
|
||||||
|
Assert.Contains("\"rowsAffected\":1", evt.Extra);
|
||||||
|
// Central stamps IngestedAtUtc; the site never sets it.
|
||||||
|
Assert.NotNull(evt.IngestedAtUtc);
|
||||||
|
Assert.StartsWith(ConnectionName, evt.Target);
|
||||||
|
}, TimeSpan.FromSeconds(15));
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task DbWrite_Select_Emits_OneCentralRow_WithExtraOpRead_AndRowsReturned()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
|
||||||
|
await using var ingestContext = CreateContext();
|
||||||
|
var ingestRepo = new AuditLogRepository(ingestContext);
|
||||||
|
var ingestActor = CreateIngestActor(ingestRepo);
|
||||||
|
|
||||||
|
await using var sqliteWriter = CreateInMemorySqliteWriter();
|
||||||
|
var ring = new RingBufferFallback();
|
||||||
|
var fallback = new FallbackAuditWriter(
|
||||||
|
sqliteWriter,
|
||||||
|
ring,
|
||||||
|
new NoOpAuditWriteFailureCounter(),
|
||||||
|
NullLogger<FallbackAuditWriter>.Instance);
|
||||||
|
var stubClient = new DirectActorSiteStreamAuditClient(ingestActor);
|
||||||
|
CreateTelemetryActor(sqliteWriter, stubClient);
|
||||||
|
|
||||||
|
using var keepAlive = new SqliteConnection("Data Source=k2;Mode=Memory;Cache=Shared");
|
||||||
|
var inner = NewInMemoryDb(out _);
|
||||||
|
var gateway = Substitute.For<IDatabaseGateway>();
|
||||||
|
gateway.GetConnectionAsync(ConnectionName, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(inner);
|
||||||
|
|
||||||
|
var helper = CreateHelper(gateway, fallback, siteId);
|
||||||
|
await using (var conn = await helper.Connection(ConnectionName))
|
||||||
|
await using (var cmd = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT id, name FROM t ORDER BY id";
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync();
|
||||||
|
var seen = 0;
|
||||||
|
while (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
seen++;
|
||||||
|
}
|
||||||
|
// Explicit close so the AuditingDbDataReader callback fires before
|
||||||
|
// the helper is disposed (Bundle A defers the audit emission to
|
||||||
|
// reader-close so rowsReturned is observable).
|
||||||
|
await reader.CloseAsync();
|
||||||
|
Assert.Equal(2, seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
await AwaitAssertAsync(async () =>
|
||||||
|
{
|
||||||
|
await using var readContext = CreateContext();
|
||||||
|
var readRepo = new AuditLogRepository(readContext);
|
||||||
|
var rows = await readRepo.QueryAsync(
|
||||||
|
new AuditLogQueryFilter(SourceSiteId: siteId),
|
||||||
|
new AuditLogPaging(PageSize: 10));
|
||||||
|
var evt = Assert.Single(rows);
|
||||||
|
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
|
||||||
|
Assert.Equal(AuditKind.DbWrite, evt.Kind);
|
||||||
|
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
||||||
|
Assert.NotNull(evt.Extra);
|
||||||
|
Assert.Contains("\"op\":\"read\"", evt.Extra);
|
||||||
|
Assert.Contains("\"rowsReturned\":2", evt.Extra);
|
||||||
|
Assert.NotNull(evt.IngestedAtUtc);
|
||||||
|
}, TimeSpan.FromSeconds(15));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ScadaLink.AuditLog.Central;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.ConfigurationDatabase;
|
||||||
|
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||||
|
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
|
||||||
|
using ScadaLink.InboundAPI.Middleware;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
|
||||||
|
namespace ScadaLink.AuditLog.Tests.Integration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 — M4 Bundle E (Task E3) end-to-end audit trail for the
|
||||||
|
/// inbound API surface. Wires the production
|
||||||
|
/// <see cref="AuditWriteMiddleware"/> into a Microsoft.AspNetCore.TestHost
|
||||||
|
/// pipeline that mirrors the production
|
||||||
|
/// <c>UseAuthentication → UseAuditWriteMiddleware → POST /api/{methodName}</c>
|
||||||
|
/// order, with the real <see cref="CentralAuditWriter"/> backed by the per-class
|
||||||
|
/// <see cref="MsSqlMigrationFixture"/> MSSQL <c>AuditLog</c> table.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Three response shapes are covered: a happy-path 200 (with the actor
|
||||||
|
/// resolved from <see cref="HttpContext.Items"/>), a 401 unauthenticated
|
||||||
|
/// (Actor stays null, kind flips to
|
||||||
|
/// <see cref="AuditKind.InboundAuthFailure"/>), and a 500 internal-error
|
||||||
|
/// response. Each test uses a unique method name so concurrent tests sharing
|
||||||
|
/// the fixture don't interfere.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The middleware-level unit tests already cover the recording-writer shape
|
||||||
|
/// (<c>AuditWriteMiddlewareTests</c>) and the pipeline ordering
|
||||||
|
/// (<c>MiddlewareOrderTests</c>); these tests verify the END-TO-END
|
||||||
|
/// materialisation in the central <c>AuditLog</c> table — the production
|
||||||
|
/// glue from request → writer → repository → MSSQL row.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
|
||||||
|
{
|
||||||
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
public InboundApiAuditTests(MsSqlMigrationFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-test unique method name suffix — the audit row's <c>Target</c>
|
||||||
|
/// captures it so each test can query by <see cref="AuditLogQueryFilter.Target"/>
|
||||||
|
/// without disturbing other tests using the same MSSQL fixture.
|
||||||
|
/// </summary>
|
||||||
|
private static string NewMethodName(string prefix) =>
|
||||||
|
prefix + "-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||||
|
|
||||||
|
private ScadaLinkDbContext CreateContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||||
|
.UseSqlServer(_fixture.ConnectionString)
|
||||||
|
.ConfigureWarnings(w => w.Ignore(
|
||||||
|
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))
|
||||||
|
.Options;
|
||||||
|
return new ScadaLinkDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spins up a minimal in-memory ASP.NET host whose pipeline mirrors the
|
||||||
|
/// production arrangement. The endpoint handler delegate is supplied by
|
||||||
|
/// each test so it can shape the response (200 with an actor, 401
|
||||||
|
/// auth-fail, 500 server error) the way the production handler would.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<IHost> BuildHostAsync(RequestDelegate endpointHandler)
|
||||||
|
{
|
||||||
|
var hostBuilder = new HostBuilder()
|
||||||
|
.ConfigureWebHost(webBuilder =>
|
||||||
|
{
|
||||||
|
webBuilder
|
||||||
|
.UseTestServer()
|
||||||
|
.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
// Real EF DbContext + AuditLogRepository wired against
|
||||||
|
// the per-class MSSQL fixture. CentralAuditWriter is a
|
||||||
|
// singleton — same pattern the production Host uses —
|
||||||
|
// opening a fresh scope per call to resolve the scoped
|
||||||
|
// repository.
|
||||||
|
services.AddDbContext<ScadaLinkDbContext>(opts =>
|
||||||
|
opts.UseSqlServer(_fixture.ConnectionString)
|
||||||
|
.ConfigureWarnings(w => w.Ignore(
|
||||||
|
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
||||||
|
services.AddScoped<IAuditLogRepository>(sp =>
|
||||||
|
new AuditLogRepository(sp.GetRequiredService<ScadaLinkDbContext>()));
|
||||||
|
services.AddSingleton<ICentralAuditWriter>(sp =>
|
||||||
|
new CentralAuditWriter(sp, NullLogger<CentralAuditWriter>.Instance));
|
||||||
|
|
||||||
|
services.AddRouting();
|
||||||
|
services.AddAuthorization();
|
||||||
|
services.AddAuthentication("TestScheme")
|
||||||
|
.AddScheme<AuthenticationSchemeOptions, AlwaysAuthenticatedHandler>(
|
||||||
|
"TestScheme", _ => { });
|
||||||
|
})
|
||||||
|
.Configure(app =>
|
||||||
|
{
|
||||||
|
// Mirror production order: routing → auth → audit
|
||||||
|
// middleware → endpoint. The auth scheme always
|
||||||
|
// succeeds; per-request auth-failure semantics are
|
||||||
|
// produced INSIDE the endpoint handler (mirroring
|
||||||
|
// ApiKeyValidator's in-handler short-circuit).
|
||||||
|
app.UseRouting();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.UseAuditWriteMiddleware();
|
||||||
|
app.UseEndpoints(endpoints =>
|
||||||
|
{
|
||||||
|
endpoints.MapPost("/api/{methodName}", endpointHandler);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return await hostBuilder.StartAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal authentication handler that always succeeds — keeps
|
||||||
|
/// <see cref="HttpContext.User"/> populated so the middleware's
|
||||||
|
/// Items-then-User fallback path has a real principal to ignore. The
|
||||||
|
/// middleware's primary actor resolution path uses
|
||||||
|
/// <see cref="AuditWriteMiddleware.AuditActorItemKey"/> so this handler's
|
||||||
|
/// claim never appears on the emitted Actor unless the endpoint stashes
|
||||||
|
/// it explicitly.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class AlwaysAuthenticatedHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||||
|
{
|
||||||
|
public AlwaysAuthenticatedHandler(
|
||||||
|
Microsoft.Extensions.Options.IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||||
|
Microsoft.Extensions.Logging.ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder)
|
||||||
|
: base(options, logger, encoder) { }
|
||||||
|
|
||||||
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
var identity = new ClaimsIdentity(
|
||||||
|
new[] { new Claim(ClaimTypes.Name, "framework-user") }, "TestScheme");
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
var ticket = new AuthenticationTicket(principal, "TestScheme");
|
||||||
|
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Queries the central <c>AuditLog</c> table for the row produced by the
|
||||||
|
/// test's unique method name. Wrapped in <see cref="Assert"/> calls so the
|
||||||
|
/// query can be used inside a polling helper.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<IReadOnlyList<AuditEvent>> QueryByTargetAsync(string methodName)
|
||||||
|
{
|
||||||
|
await using var ctx = CreateContext();
|
||||||
|
var repo = new AuditLogRepository(ctx);
|
||||||
|
return await repo.QueryAsync(
|
||||||
|
new AuditLogQueryFilter(Target: methodName),
|
||||||
|
new AuditLogPaging(PageSize: 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Awaits the central <c>AuditLog</c> row materialising for
|
||||||
|
/// <paramref name="methodName"/>. The writer is fire-and-forget so we
|
||||||
|
/// poll briefly after the HTTP response returns to absorb scheduling
|
||||||
|
/// jitter between the middleware's <c>finally</c> block and the row hitting
|
||||||
|
/// MSSQL.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<AuditEvent> AwaitOneAsync(string methodName)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
var rows = await QueryByTargetAsync(methodName);
|
||||||
|
if (rows.Count > 0)
|
||||||
|
{
|
||||||
|
return Assert.Single(rows);
|
||||||
|
}
|
||||||
|
await Task.Delay(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall through to a final query so the failure message carries the
|
||||||
|
// actual row count from the last attempt.
|
||||||
|
var finalRows = await QueryByTargetAsync(methodName);
|
||||||
|
return Assert.Single(finalRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task PostToApi_WithValidActor_Emits_InboundRequest_StatusDelivered_HttpStatus200_ActorPopulated()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
var methodName = NewMethodName("echo");
|
||||||
|
|
||||||
|
using var host = await BuildHostAsync(async ctx =>
|
||||||
|
{
|
||||||
|
// Simulate the production endpoint stashing the resolved API key
|
||||||
|
// name on HttpContext.Items AFTER successful auth — the middleware
|
||||||
|
// reads it in its finally block to populate Actor.
|
||||||
|
ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "integration-svc";
|
||||||
|
ctx.Response.StatusCode = 200;
|
||||||
|
await ctx.Response.WriteAsync("ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
var client = host.GetTestClient();
|
||||||
|
var resp = await client.PostAsync(
|
||||||
|
$"/api/{methodName}",
|
||||||
|
new StringContent("{\"x\":1}", Encoding.UTF8, "application/json"));
|
||||||
|
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("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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task PostToApi_Without_Auth_Emits_InboundAuthFailure_StatusFailed_HttpStatus401_ActorNull()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
var methodName = NewMethodName("auth-fail");
|
||||||
|
|
||||||
|
using var host = await BuildHostAsync(async ctx =>
|
||||||
|
{
|
||||||
|
// The production ApiKeyValidator returns 401 from inside the
|
||||||
|
// handler when the X-API-Key header is missing or invalid; the
|
||||||
|
// handler must NOT stash an actor name in that case so the
|
||||||
|
// middleware emits Actor=null on the resulting audit row.
|
||||||
|
ctx.Response.StatusCode = 401;
|
||||||
|
await ctx.Response.WriteAsync("unauthorized");
|
||||||
|
});
|
||||||
|
|
||||||
|
var client = host.GetTestClient();
|
||||||
|
var resp = await client.PostAsync(
|
||||||
|
$"/api/{methodName}",
|
||||||
|
new StringContent("{}", Encoding.UTF8, "application/json"));
|
||||||
|
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);
|
||||||
|
// Never echo back an unauthenticated principal — middleware suppresses
|
||||||
|
// the framework user resolution on 401/403 paths.
|
||||||
|
Assert.Null(evt.Actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task PostToApi_Returning500_Emits_InboundRequest_StatusFailed_HttpStatus500()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
var methodName = NewMethodName("server-error");
|
||||||
|
|
||||||
|
using var host = await BuildHostAsync(async ctx =>
|
||||||
|
{
|
||||||
|
// A handler-returned 500 (not a throw) — auth succeeded so Actor
|
||||||
|
// resolution is still allowed; the audit row's Kind stays
|
||||||
|
// InboundRequest (not InboundAuthFailure) and Status flips to
|
||||||
|
// Failed because the response is not a 2xx.
|
||||||
|
ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "integration-svc";
|
||||||
|
ctx.Response.StatusCode = 500;
|
||||||
|
await ctx.Response.WriteAsync("kaboom");
|
||||||
|
});
|
||||||
|
|
||||||
|
var client = host.GetTestClient();
|
||||||
|
var resp = await client.PostAsync(
|
||||||
|
$"/api/{methodName}",
|
||||||
|
new StringContent("{}", Encoding.UTF8, "application/json"));
|
||||||
|
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("integration-svc", evt.Actor);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ScadaLink.AuditLog.Central;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Entities.Notifications;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.ConfigurationDatabase;
|
||||||
|
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||||
|
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
|
||||||
|
using ScadaLink.NotificationOutbox;
|
||||||
|
using ScadaLink.NotificationOutbox.Delivery;
|
||||||
|
using ScadaLink.NotificationOutbox.Messages;
|
||||||
|
|
||||||
|
namespace ScadaLink.AuditLog.Tests.Integration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 — M4 Bundle E (Task E2): end-to-end audit trail produced by
|
||||||
|
/// the central <see cref="NotificationOutboxActor"/> dispatcher loop. Wires
|
||||||
|
/// the production <see cref="CentralAuditWriter"/> onto the real
|
||||||
|
/// <see cref="AuditLogRepository"/> against the per-class
|
||||||
|
/// <see cref="MsSqlMigrationFixture"/> MSSQL database, drives the dispatcher
|
||||||
|
/// with a stub <see cref="INotificationDeliveryAdapter"/> that yields a
|
||||||
|
/// transient-then-success sequence, and asserts the resulting
|
||||||
|
/// <see cref="AuditChannel.Notification"/>/<see cref="AuditKind.NotifyDeliver"/>
|
||||||
|
/// rows materialise with the expected Attempted/Delivered shape.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The Submit row is normally produced by the site-side <c>Notify.Send</c>
|
||||||
|
/// wrapper (Bundle C); for this E2E we pre-insert a single AuditLog Submit row
|
||||||
|
/// via <see cref="IAuditLogRepository"/> alongside the seeded
|
||||||
|
/// <see cref="Notification"/> row so the assertions can confirm the dispatcher
|
||||||
|
/// emissions slot in alongside it. This keeps the test focused on the
|
||||||
|
/// dispatcher's emission shape without depending on the upstream site path.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Each test uses a unique notification id + source-site id so concurrent
|
||||||
|
/// tests sharing the MSSQL fixture don't interfere. The dispatcher is driven
|
||||||
|
/// deterministically via the internal
|
||||||
|
/// <c>InternalMessages.DispatchTick.Instance</c> sentinel (same pattern the
|
||||||
|
/// existing NotificationOutbox.Tests use).
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
||||||
|
{
|
||||||
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
public NotifyDispatcherAuditTrailTests(MsSqlMigrationFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NewSiteId() =>
|
||||||
|
"test-e2-notify-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||||
|
|
||||||
|
private ScadaLinkDbContext CreateContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||||
|
.UseSqlServer(_fixture.ConnectionString)
|
||||||
|
.ConfigureWarnings(w => w.Ignore(
|
||||||
|
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))
|
||||||
|
.Options;
|
||||||
|
return new ScadaLinkDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a DI provider that mirrors the production wiring expected by
|
||||||
|
/// <see cref="NotificationOutboxActor"/>: scoped EF-backed
|
||||||
|
/// <see cref="INotificationOutboxRepository"/> + <see cref="INotificationRepository"/>
|
||||||
|
/// + the supplied <see cref="INotificationDeliveryAdapter"/>. The
|
||||||
|
/// <see cref="IAuditLogRepository"/> registration powers the
|
||||||
|
/// <see cref="CentralAuditWriter"/> the actor will emit through.
|
||||||
|
/// </summary>
|
||||||
|
private IServiceProvider BuildServiceProvider(INotificationDeliveryAdapter adapter)
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddDbContext<ScadaLinkDbContext>(opts =>
|
||||||
|
opts.UseSqlServer(_fixture.ConnectionString)
|
||||||
|
.ConfigureWarnings(w => w.Ignore(
|
||||||
|
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
||||||
|
services.AddScoped<INotificationOutboxRepository>(sp =>
|
||||||
|
new NotificationOutboxRepository(sp.GetRequiredService<ScadaLinkDbContext>()));
|
||||||
|
services.AddScoped<INotificationRepository>(sp =>
|
||||||
|
new NotificationRepository(sp.GetRequiredService<ScadaLinkDbContext>()));
|
||||||
|
services.AddScoped<IAuditLogRepository>(sp =>
|
||||||
|
new AuditLogRepository(sp.GetRequiredService<ScadaLinkDbContext>()));
|
||||||
|
services.AddScoped<INotificationDeliveryAdapter>(_ => adapter);
|
||||||
|
return services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stub adapter that yields the next outcome from a configurable queue per
|
||||||
|
/// call. Lets a single dispatch sweep exercise the transient-then-success
|
||||||
|
/// transition by alternating <see cref="DeliveryResult.TransientFailure"/>
|
||||||
|
/// and <see cref="DeliveryResult.Success"/>.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class QueuedOutcomeAdapter : INotificationDeliveryAdapter
|
||||||
|
{
|
||||||
|
private readonly Queue<DeliveryOutcome> _outcomes;
|
||||||
|
public int CallCount;
|
||||||
|
|
||||||
|
public QueuedOutcomeAdapter(params DeliveryOutcome[] outcomes)
|
||||||
|
{
|
||||||
|
_outcomes = new Queue<DeliveryOutcome>(outcomes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public NotificationType Type => NotificationType.Email;
|
||||||
|
|
||||||
|
public Task<DeliveryOutcome> DeliverAsync(
|
||||||
|
Notification notification, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref CallCount);
|
||||||
|
// Defensive — if a test under-supplies outcomes we surface the
|
||||||
|
// problem as an explicit transient failure rather than throwing
|
||||||
|
// (the dispatcher would log + skip the notification but the audit
|
||||||
|
// assertions would be misleading).
|
||||||
|
var outcome = _outcomes.Count > 0
|
||||||
|
? _outcomes.Dequeue()
|
||||||
|
: DeliveryOutcome.Transient("test stub out of outcomes");
|
||||||
|
return Task.FromResult(outcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inserts a single SMTP configuration row so the dispatcher's
|
||||||
|
/// <c>ResolveRetryPolicyAsync</c> sees a real (maxRetries, retryDelay)
|
||||||
|
/// pair rather than the conservative fallback. RetryDelay of 0 means a
|
||||||
|
/// transient outcome's <c>NextAttemptAt</c> is immediately due — useful so
|
||||||
|
/// the SECOND DispatchTick re-claims the row without waiting.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SeedSmtpConfigAsync(int maxRetries = 5)
|
||||||
|
{
|
||||||
|
await using var ctx = CreateContext();
|
||||||
|
ctx.SmtpConfigurations.Add(new SmtpConfiguration(
|
||||||
|
"smtp.example.com", "Basic", "noreply@example.com")
|
||||||
|
{
|
||||||
|
MaxRetries = maxRetries,
|
||||||
|
RetryDelay = TimeSpan.Zero,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds the Pending outbox row the dispatcher will claim. Using a fixed
|
||||||
|
/// caller-supplied <c>notificationId</c> so the test can later query the
|
||||||
|
/// AuditLog by <see cref="AuditEvent.CorrelationId"/> = notificationId.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<Notification> SeedNotificationAsync(
|
||||||
|
Guid notificationId, string siteId, string listName = "ops-team")
|
||||||
|
{
|
||||||
|
await using var ctx = CreateContext();
|
||||||
|
var n = new Notification(
|
||||||
|
notificationId.ToString("D"),
|
||||||
|
NotificationType.Email,
|
||||||
|
listName,
|
||||||
|
"Tank overflow",
|
||||||
|
"Tank 3 level critical",
|
||||||
|
siteId)
|
||||||
|
{
|
||||||
|
SourceInstanceId = "Plant.Pump42",
|
||||||
|
SourceScript = "AlarmScript",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||||
|
};
|
||||||
|
ctx.Notifications.Add(n);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-inserts the Submit AuditLog row that the site-side Notify.Send
|
||||||
|
/// wrapper would have emitted (Bundle C). Keeps the assertions on the
|
||||||
|
/// dispatcher emissions intact without depending on the upstream site
|
||||||
|
/// path.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SeedSubmitAuditRowAsync(Guid notificationId, string siteId)
|
||||||
|
{
|
||||||
|
await using var ctx = CreateContext();
|
||||||
|
var repo = new AuditLogRepository(ctx);
|
||||||
|
var submitEvt = new AuditEvent
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = DateTime.UtcNow.AddMinutes(-1),
|
||||||
|
Channel = AuditChannel.Notification,
|
||||||
|
Kind = AuditKind.NotifySend,
|
||||||
|
CorrelationId = notificationId,
|
||||||
|
SourceSiteId = siteId,
|
||||||
|
SourceInstanceId = "Plant.Pump42",
|
||||||
|
SourceScript = "AlarmScript",
|
||||||
|
Target = "ops-team",
|
||||||
|
Status = AuditStatus.Submitted,
|
||||||
|
ForwardState = AuditForwardState.Forwarded,
|
||||||
|
IngestedAtUtc = DateTime.UtcNow.AddMinutes(-1),
|
||||||
|
};
|
||||||
|
await repo.InsertIfNotExistsAsync(submitEvt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NotificationOutboxOptions LongDispatchOptions() =>
|
||||||
|
// 1h dispatch + 24h purge so PreStart's timers never fire during the
|
||||||
|
// test; the test drives the dispatcher with explicit DispatchTick.
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
DispatchInterval = TimeSpan.FromHours(1),
|
||||||
|
PurgeInterval = TimeSpan.FromDays(1),
|
||||||
|
};
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task NotifyDispatcher_FailThenSuccess_Emits_TwoAttempts_OneDelivered_Terminal()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
var notificationId = Guid.NewGuid();
|
||||||
|
await SeedSmtpConfigAsync(maxRetries: 5);
|
||||||
|
await SeedNotificationAsync(notificationId, siteId);
|
||||||
|
await SeedSubmitAuditRowAsync(notificationId, siteId);
|
||||||
|
|
||||||
|
var adapter = new QueuedOutcomeAdapter(
|
||||||
|
DeliveryOutcome.Transient("smtp 421 try again"),
|
||||||
|
DeliveryOutcome.Success("ops@example.com"));
|
||||||
|
var serviceProvider = BuildServiceProvider(adapter);
|
||||||
|
var auditWriter = new CentralAuditWriter(
|
||||||
|
serviceProvider,
|
||||||
|
NullLogger<CentralAuditWriter>.Instance);
|
||||||
|
|
||||||
|
var actor = Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||||
|
serviceProvider,
|
||||||
|
LongDispatchOptions(),
|
||||||
|
(ICentralAuditWriter)auditWriter,
|
||||||
|
NullLogger<NotificationOutboxActor>.Instance)));
|
||||||
|
|
||||||
|
// First tick: transient failure → one Attempted row, no terminal row.
|
||||||
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
await AwaitAssertAsync(async () =>
|
||||||
|
{
|
||||||
|
await using var ctx = CreateContext();
|
||||||
|
var repo = new AuditLogRepository(ctx);
|
||||||
|
var rows = await repo.QueryAsync(
|
||||||
|
new AuditLogQueryFilter(SourceSiteId: siteId),
|
||||||
|
new AuditLogPaging(PageSize: 50));
|
||||||
|
// 1 Submit + 1 Attempted = 2 rows so far.
|
||||||
|
Assert.Equal(2, rows.Count);
|
||||||
|
Assert.Single(rows, r => r.Kind == AuditKind.NotifyDeliver
|
||||||
|
&& r.Status == AuditStatus.Attempted);
|
||||||
|
Assert.Single(rows, r => r.Kind == AuditKind.NotifySend);
|
||||||
|
}, TimeSpan.FromSeconds(15));
|
||||||
|
|
||||||
|
// Second tick: success → second Attempted + one Delivered terminal.
|
||||||
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
await AwaitAssertAsync(async () =>
|
||||||
|
{
|
||||||
|
await using var ctx = CreateContext();
|
||||||
|
var repo = new AuditLogRepository(ctx);
|
||||||
|
var rows = await repo.QueryAsync(
|
||||||
|
new AuditLogQueryFilter(SourceSiteId: siteId),
|
||||||
|
new AuditLogPaging(PageSize: 50));
|
||||||
|
// 1 Submit + 2 Attempted + 1 Delivered terminal = 4 rows.
|
||||||
|
Assert.InRange(rows.Count, 3, 4);
|
||||||
|
var notifyDeliverRows = rows
|
||||||
|
.Where(r => r.Kind == AuditKind.NotifyDeliver)
|
||||||
|
.ToList();
|
||||||
|
Assert.Equal(2, notifyDeliverRows.Count(r => r.Status == AuditStatus.Attempted));
|
||||||
|
var terminal = Assert.Single(notifyDeliverRows, r => r.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);
|
||||||
|
}, TimeSpan.FromSeconds(15));
|
||||||
|
|
||||||
|
// Operational Notifications table mirrors the audit outcome.
|
||||||
|
await AwaitAssertAsync(async () =>
|
||||||
|
{
|
||||||
|
await using var ctx = CreateContext();
|
||||||
|
var n = await ctx.Notifications.SingleAsync(
|
||||||
|
row => row.NotificationId == notificationId.ToString("D"));
|
||||||
|
Assert.Equal(NotificationStatus.Delivered, n.Status);
|
||||||
|
Assert.NotNull(n.DeliveredAt);
|
||||||
|
}, TimeSpan.FromSeconds(15));
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task NotifyDispatcher_AuditWriter_Throws_DeliveryStillSucceeds()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
var notificationId = Guid.NewGuid();
|
||||||
|
await SeedSmtpConfigAsync(maxRetries: 5);
|
||||||
|
await SeedNotificationAsync(notificationId, siteId);
|
||||||
|
|
||||||
|
var adapter = new QueuedOutcomeAdapter(
|
||||||
|
DeliveryOutcome.Success("ops@example.com"));
|
||||||
|
var serviceProvider = BuildServiceProvider(adapter);
|
||||||
|
|
||||||
|
// ALWAYS-throw writer wired in place of the production
|
||||||
|
// CentralAuditWriter. The dispatcher MUST still deliver the
|
||||||
|
// notification and persist the terminal Delivered transition
|
||||||
|
// regardless of the audit subsystem being down (alog.md §13).
|
||||||
|
var throwingWriter = new ThrowingCentralAuditWriter();
|
||||||
|
|
||||||
|
var actor = Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||||
|
serviceProvider,
|
||||||
|
LongDispatchOptions(),
|
||||||
|
(ICentralAuditWriter)throwingWriter,
|
||||||
|
NullLogger<NotificationOutboxActor>.Instance)));
|
||||||
|
|
||||||
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
|
||||||
|
// The Notifications table is the operational source of truth — assert
|
||||||
|
// it transitions to Delivered even though every audit write threw.
|
||||||
|
await AwaitAssertAsync(async () =>
|
||||||
|
{
|
||||||
|
await using var ctx = CreateContext();
|
||||||
|
var n = await ctx.Notifications.SingleAsync(
|
||||||
|
row => row.NotificationId == notificationId.ToString("D"));
|
||||||
|
Assert.Equal(NotificationStatus.Delivered, n.Status);
|
||||||
|
Assert.NotNull(n.DeliveredAt);
|
||||||
|
}, TimeSpan.FromSeconds(15));
|
||||||
|
|
||||||
|
// The writer was attempted (at least once for the Attempted row, plus
|
||||||
|
// once for the Delivered terminal) — proves the dispatcher tried to
|
||||||
|
// emit and absorbed the throws rather than aborting the action.
|
||||||
|
Assert.True(throwingWriter.AttemptCount >= 2,
|
||||||
|
$"Expected the dispatcher to attempt audit writes; saw {throwingWriter.AttemptCount}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test-only <see cref="ICentralAuditWriter"/> that ALWAYS throws on
|
||||||
|
/// <see cref="WriteAsync"/>. Used to verify the dispatcher's defensive
|
||||||
|
/// try/catch contract (alog.md §13) — audit failures must NEVER abort
|
||||||
|
/// the user-facing notification delivery.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ThrowingCentralAuditWriter : ICentralAuditWriter
|
||||||
|
{
|
||||||
|
private int _attemptCount;
|
||||||
|
public int AttemptCount => Volatile.Read(ref _attemptCount);
|
||||||
|
|
||||||
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _attemptCount);
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"test-only ThrowingCentralAuditWriter — audit subsystem unavailable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,9 +20,13 @@
|
|||||||
<PackageReference Include="Microsoft.Data.SqlClient" VersionOverride="6.1.1" />
|
<PackageReference Include="Microsoft.Data.SqlClient" VersionOverride="6.1.1" />
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
<!--
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
M4 Bundle E (Task E3): Microsoft.Extensions.Configuration.Json,
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
DependencyInjection, and Logging.Abstractions are now provided by the
|
||||||
|
Microsoft.AspNetCore.App framework reference below (pulled in for the
|
||||||
|
TestHost-based middleware E2E) so we drop them as explicit package
|
||||||
|
references to satisfy the warn-as-error pruning rule.
|
||||||
|
-->
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
<PackageReference Include="NSubstitute" />
|
<PackageReference Include="NSubstitute" />
|
||||||
<PackageReference Include="xunit" />
|
<PackageReference Include="xunit" />
|
||||||
@@ -55,6 +59,26 @@
|
|||||||
needs a project reference to SiteRuntime where the store lives.
|
needs a project reference to SiteRuntime where the store lives.
|
||||||
-->
|
-->
|
||||||
<ProjectReference Include="../../src/ScadaLink.SiteRuntime/ScadaLink.SiteRuntime.csproj" />
|
<ProjectReference Include="../../src/ScadaLink.SiteRuntime/ScadaLink.SiteRuntime.csproj" />
|
||||||
|
<!--
|
||||||
|
M4 Bundle E (Task E2): the dispatcher audit-trail end-to-end test
|
||||||
|
constructs the production NotificationOutboxActor against the real
|
||||||
|
CentralAuditWriter so the Attempted/Delivered NotifyDeliver rows land in
|
||||||
|
the central MSSQL AuditLog table.
|
||||||
|
-->
|
||||||
|
<ProjectReference Include="../../src/ScadaLink.NotificationOutbox/ScadaLink.NotificationOutbox.csproj" />
|
||||||
|
<!--
|
||||||
|
M4 Bundle E (Task E3): the inbound API audit-trail end-to-end test wires
|
||||||
|
the production AuditWriteMiddleware into a TestHost pipeline and asserts
|
||||||
|
one InboundRequest/InboundAuthFailure row per request lands in the
|
||||||
|
central MSSQL AuditLog.
|
||||||
|
-->
|
||||||
|
<ProjectReference Include="../../src/ScadaLink.InboundAPI/ScadaLink.InboundAPI.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- M4 Bundle E (Task E3): need ASP.NET Core for the TestHost-based middleware E2E. -->
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.InboundAPI.Middleware;
|
||||||
|
|
||||||
|
namespace ScadaLink.InboundAPI.Tests.Middleware;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M4 Bundle D (D1) — verifies <see cref="AuditWriteMiddleware"/> emits exactly one
|
||||||
|
/// <see cref="AuditChannel.ApiInbound"/> row per request via
|
||||||
|
/// <see cref="ICentralAuditWriter"/> covering all outcome shapes:
|
||||||
|
/// success (InboundRequest/Delivered), client/server error (InboundRequest/Failed),
|
||||||
|
/// and unauthenticated (InboundAuthFailure/Failed). Audit-write failures must NEVER
|
||||||
|
/// alter the HTTP response (alog.md §13).
|
||||||
|
/// </summary>
|
||||||
|
public class AuditWriteMiddlewareTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Test-only recording <see cref="ICentralAuditWriter"/>. Captures every
|
||||||
|
/// <see cref="AuditEvent"/> the middleware emits so each test can assert on
|
||||||
|
/// the shape of the row produced for one request.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class RecordingAuditWriter : ICentralAuditWriter
|
||||||
|
{
|
||||||
|
public List<AuditEvent> Events { get; } = new();
|
||||||
|
public Func<AuditEvent, Task>? OnWrite { get; set; }
|
||||||
|
|
||||||
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
lock (Events)
|
||||||
|
{
|
||||||
|
Events.Add(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return OnWrite?.Invoke(evt) ?? Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an <see cref="HttpContext"/> primed for the inbound API route shape:
|
||||||
|
/// POST /api/{methodName}, optional JSON body, RemoteIpAddress + User-Agent.
|
||||||
|
/// The route value resolver mirrors the production endpoint mapping so the
|
||||||
|
/// middleware can pull the method name without owning routing itself.
|
||||||
|
/// </summary>
|
||||||
|
private static DefaultHttpContext BuildContext(
|
||||||
|
string methodName = "echo",
|
||||||
|
string? body = null,
|
||||||
|
string? userAgent = "test-agent/1.0",
|
||||||
|
IPAddress? remoteIp = null)
|
||||||
|
{
|
||||||
|
var ctx = new DefaultHttpContext();
|
||||||
|
ctx.Request.Method = "POST";
|
||||||
|
ctx.Request.Path = $"/api/{methodName}";
|
||||||
|
ctx.Request.RouteValues["methodName"] = methodName;
|
||||||
|
|
||||||
|
if (body is not null)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(body);
|
||||||
|
ctx.Request.Body = new MemoryStream(bytes);
|
||||||
|
ctx.Request.ContentLength = bytes.Length;
|
||||||
|
ctx.Request.ContentType = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userAgent is not null)
|
||||||
|
{
|
||||||
|
ctx.Request.Headers["User-Agent"] = userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Connection.RemoteIpAddress = remoteIp ?? IPAddress.Parse("10.0.0.5");
|
||||||
|
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuditWriteMiddleware CreateMiddleware(
|
||||||
|
RequestDelegate next,
|
||||||
|
ICentralAuditWriter writer) =>
|
||||||
|
new(next, writer, NullLogger<AuditWriteMiddleware>.Instance);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// 1. Happy path — InboundRequest/Delivered/HttpStatus 200
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Pipeline_Success_EmitsOneEvent_KindInboundRequest_StatusDelivered_HttpStatus200()
|
||||||
|
{
|
||||||
|
var writer = new RecordingAuditWriter();
|
||||||
|
var ctx = BuildContext();
|
||||||
|
var mw = CreateMiddleware(_ =>
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 200;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}, writer);
|
||||||
|
|
||||||
|
await mw.InvokeAsync(ctx);
|
||||||
|
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.Equal(AuditChannel.ApiInbound, evt.Channel);
|
||||||
|
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);
|
||||||
|
Assert.NotEqual(Guid.Empty, evt.EventId);
|
||||||
|
Assert.Equal("echo", evt.Target);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// 2. 400 — script/validation failure path
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Pipeline_400_EmitsEvent_Status_Failed_HttpStatus400()
|
||||||
|
{
|
||||||
|
var writer = new RecordingAuditWriter();
|
||||||
|
var ctx = BuildContext();
|
||||||
|
var mw = CreateMiddleware(_ =>
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 400;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}, writer);
|
||||||
|
|
||||||
|
await mw.InvokeAsync(ctx);
|
||||||
|
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
// A 400 is a request the auth succeeded for — still InboundRequest, not
|
||||||
|
// InboundAuthFailure. Only 401/403 maps to the auth-failure kind.
|
||||||
|
Assert.Equal(AuditKind.InboundRequest, evt.Kind);
|
||||||
|
Assert.Equal(AuditStatus.Failed, evt.Status);
|
||||||
|
Assert.Equal(400, evt.HttpStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// 3. 401 — auth failure path
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Pipeline_401_EmitsEvent_KindInboundAuthFailure_StatusFailed()
|
||||||
|
{
|
||||||
|
var writer = new RecordingAuditWriter();
|
||||||
|
var ctx = BuildContext();
|
||||||
|
var mw = CreateMiddleware(_ =>
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 401;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}, writer);
|
||||||
|
|
||||||
|
await mw.InvokeAsync(ctx);
|
||||||
|
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.Equal(AuditKind.InboundAuthFailure, evt.Kind);
|
||||||
|
Assert.Equal(AuditStatus.Failed, evt.Status);
|
||||||
|
Assert.Equal(401, evt.HttpStatus);
|
||||||
|
// The candidate API key never resolved to a name, so Actor stays null —
|
||||||
|
// never echo back an unauthenticated principal.
|
||||||
|
Assert.Null(evt.Actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Pipeline_403_EmitsEvent_KindInboundAuthFailure_StatusFailed()
|
||||||
|
{
|
||||||
|
var writer = new RecordingAuditWriter();
|
||||||
|
var ctx = BuildContext();
|
||||||
|
var mw = CreateMiddleware(_ =>
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 403;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}, writer);
|
||||||
|
|
||||||
|
await mw.InvokeAsync(ctx);
|
||||||
|
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.Equal(AuditKind.InboundAuthFailure, evt.Kind);
|
||||||
|
Assert.Equal(AuditStatus.Failed, evt.Status);
|
||||||
|
Assert.Equal(403, evt.HttpStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// 4. 500 — handler threw OR returned 500
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Pipeline_500_EmitsEvent_Status_Failed()
|
||||||
|
{
|
||||||
|
var writer = new RecordingAuditWriter();
|
||||||
|
var ctx = BuildContext();
|
||||||
|
var mw = CreateMiddleware(_ =>
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 500;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}, writer);
|
||||||
|
|
||||||
|
await mw.InvokeAsync(ctx);
|
||||||
|
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.Equal(AuditKind.InboundRequest, evt.Kind);
|
||||||
|
Assert.Equal(AuditStatus.Failed, evt.Status);
|
||||||
|
Assert.Equal(500, evt.HttpStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Pipeline_Throws_EmitsEvent_Status_Failed_And_Rethrows()
|
||||||
|
{
|
||||||
|
var writer = new RecordingAuditWriter();
|
||||||
|
var ctx = BuildContext();
|
||||||
|
var boom = new InvalidOperationException("kaboom");
|
||||||
|
var mw = CreateMiddleware(_ => throw boom, writer);
|
||||||
|
|
||||||
|
// The middleware MUST re-throw so the request's own error path is
|
||||||
|
// authoritative — audit emission is best-effort only.
|
||||||
|
var thrown = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => mw.InvokeAsync(ctx));
|
||||||
|
Assert.Same(boom, thrown);
|
||||||
|
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.Equal(AuditStatus.Failed, evt.Status);
|
||||||
|
Assert.Equal("kaboom", evt.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// 5. Actor resolution — the endpoint handler stashes the API key name
|
||||||
|
// AFTER successful auth so the middleware can pick it up from
|
||||||
|
// HttpContext.Items.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApiKeyName_Resolved_From_HttpContext_AsActor()
|
||||||
|
{
|
||||||
|
var writer = new RecordingAuditWriter();
|
||||||
|
var ctx = BuildContext();
|
||||||
|
var mw = CreateMiddleware(_ =>
|
||||||
|
{
|
||||||
|
// The endpoint handler is expected to stash the resolved API key
|
||||||
|
// name here once ApiKeyValidator.ValidateAsync has succeeded.
|
||||||
|
ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "integration-svc";
|
||||||
|
ctx.Response.StatusCode = 200;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}, writer);
|
||||||
|
|
||||||
|
await mw.InvokeAsync(ctx);
|
||||||
|
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.Equal("integration-svc", evt.Actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// 6. Writer failure must NEVER alter the HTTP response
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuditWriter_Throws_HttpResponse_Unchanged_Success_Stays_Success()
|
||||||
|
{
|
||||||
|
var writer = new RecordingAuditWriter
|
||||||
|
{
|
||||||
|
OnWrite = _ => throw new InvalidOperationException("writer offline"),
|
||||||
|
};
|
||||||
|
var ctx = BuildContext();
|
||||||
|
var mw = CreateMiddleware(_ =>
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 200;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}, writer);
|
||||||
|
|
||||||
|
// Audit emission is best-effort; even a thrown writer must NOT bubble
|
||||||
|
// up and contaminate the user-facing response status.
|
||||||
|
await mw.InvokeAsync(ctx);
|
||||||
|
|
||||||
|
Assert.Equal(200, ctx.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuditWriter_Throws_OnFailedRequest_HttpResponse_Unchanged()
|
||||||
|
{
|
||||||
|
var writer = new RecordingAuditWriter
|
||||||
|
{
|
||||||
|
OnWrite = _ => throw new InvalidOperationException("writer offline"),
|
||||||
|
};
|
||||||
|
var ctx = BuildContext();
|
||||||
|
var mw = CreateMiddleware(_ =>
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 500;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}, writer);
|
||||||
|
|
||||||
|
await mw.InvokeAsync(ctx);
|
||||||
|
|
||||||
|
Assert.Equal(500, ctx.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// 7. Provenance — RemoteIp + User-Agent surface in Extra JSON
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoteIp_And_UserAgent_AppearInExtra()
|
||||||
|
{
|
||||||
|
var writer = new RecordingAuditWriter();
|
||||||
|
var ctx = BuildContext(
|
||||||
|
userAgent: "curl/8.4.0",
|
||||||
|
remoteIp: IPAddress.Parse("192.168.50.50"));
|
||||||
|
var mw = CreateMiddleware(_ =>
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 200;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}, writer);
|
||||||
|
|
||||||
|
await mw.InvokeAsync(ctx);
|
||||||
|
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.NotNull(evt.Extra);
|
||||||
|
using var doc = JsonDocument.Parse(evt.Extra!);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
Assert.Equal("192.168.50.50", root.GetProperty("remoteIp").GetString());
|
||||||
|
Assert.Equal("curl/8.4.0", root.GetProperty("userAgent").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Body capture — the small JSON body is buffered and stashed on
|
||||||
|
// RequestSummary so subsequent reads (the endpoint handler's
|
||||||
|
// JsonDocument.Parse) still see the full payload.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestBody_IsBuffered_AndStashed_OnRequestSummary()
|
||||||
|
{
|
||||||
|
var writer = new RecordingAuditWriter();
|
||||||
|
var requestJson = "{\"x\":1}";
|
||||||
|
var ctx = BuildContext(body: requestJson);
|
||||||
|
|
||||||
|
string? observedAfterMiddleware = null;
|
||||||
|
var mw = CreateMiddleware(async hc =>
|
||||||
|
{
|
||||||
|
// Downstream code must still be able to read the body — the
|
||||||
|
// middleware enables buffering and rewinds so the handler sees the
|
||||||
|
// unconsumed stream.
|
||||||
|
using var reader = new StreamReader(hc.Request.Body);
|
||||||
|
observedAfterMiddleware = await reader.ReadToEndAsync();
|
||||||
|
hc.Response.StatusCode = 200;
|
||||||
|
}, writer);
|
||||||
|
|
||||||
|
await mw.InvokeAsync(ctx);
|
||||||
|
|
||||||
|
Assert.Equal(requestJson, observedAfterMiddleware);
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.Equal(requestJson, evt.RequestSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DurationMs_IsRecorded()
|
||||||
|
{
|
||||||
|
var writer = new RecordingAuditWriter();
|
||||||
|
var ctx = BuildContext();
|
||||||
|
var mw = CreateMiddleware(async _ =>
|
||||||
|
{
|
||||||
|
// The middleware records elapsed milliseconds — a small delay
|
||||||
|
// ensures DurationMs is non-negative and roughly tracks reality
|
||||||
|
// without being flake-sensitive in CI.
|
||||||
|
await Task.Delay(5);
|
||||||
|
ctx.Response.StatusCode = 200;
|
||||||
|
}, writer);
|
||||||
|
|
||||||
|
await mw.InvokeAsync(ctx);
|
||||||
|
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.NotNull(evt.DurationMs);
|
||||||
|
Assert.True(evt.DurationMs >= 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.InboundAPI.Middleware;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace ScadaLink.InboundAPI.Tests.Middleware;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M4 Bundle D (D2) — verifies the production pipeline order from
|
||||||
|
/// <c>ScadaLink.Host.Program.cs</c> for the inbound API:
|
||||||
|
/// <c>UseAuthentication → UseAuthorization → UseAuditWriteMiddleware → endpoint</c>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The order is load-bearing: the audit middleware must run AFTER auth (so any
|
||||||
|
/// framework-resolved principal on <see cref="HttpContext.User"/> is in place)
|
||||||
|
/// and BEFORE the inbound-API endpoint handler (so the handler's stashed actor
|
||||||
|
/// name from <see cref="HttpContext.Items"/> is observable in the
|
||||||
|
/// <c>finally</c> block when the handler returns). The order is also what
|
||||||
|
/// guarantees auth-failure responses (401/403 produced by a future auth scheme)
|
||||||
|
/// are seen by the middleware so it can emit
|
||||||
|
/// <see cref="AuditKind.InboundAuthFailure"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public class MiddlewareOrderTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Captures the order of pipeline stages by appending a token to a shared
|
||||||
|
/// list as each stage runs. The assertion compares the resulting sequence
|
||||||
|
/// directly so a regression that re-orders the pipeline fails loudly.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class OrderingRecorder
|
||||||
|
{
|
||||||
|
public List<string> Stages { get; } = new();
|
||||||
|
public void Record(string stage)
|
||||||
|
{
|
||||||
|
lock (Stages) { Stages.Add(stage); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingAuditWriter : ICentralAuditWriter
|
||||||
|
{
|
||||||
|
public List<AuditEvent> Events { get; } = new();
|
||||||
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
lock (Events) { Events.Add(evt); }
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Middleware_Pipeline_PlacesAuditWriteAfterAuth_BeforeScriptExecution()
|
||||||
|
{
|
||||||
|
var recorder = new OrderingRecorder();
|
||||||
|
var writer = new RecordingAuditWriter();
|
||||||
|
|
||||||
|
using var host = await BuildHostAsync(recorder, writer);
|
||||||
|
var client = host.GetTestClient();
|
||||||
|
|
||||||
|
var response = await client.PostAsync("/api/echo", new StringContent("{}"));
|
||||||
|
|
||||||
|
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
|
||||||
|
|
||||||
|
// The recorder MUST observe these stages in exactly this order:
|
||||||
|
// 1. Authentication (UseAuthentication marker)
|
||||||
|
// 2. Authorization (UseAuthorization marker)
|
||||||
|
// 3. AuditMiddleware-Before-Next (audit middleware entered)
|
||||||
|
// 4. Endpoint handler (the route handler ran)
|
||||||
|
// 5. AuditMiddleware-After-Next (audit middleware completed)
|
||||||
|
Assert.Equal(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
"auth",
|
||||||
|
"authz",
|
||||||
|
"audit-before",
|
||||||
|
"endpoint",
|
||||||
|
"audit-after",
|
||||||
|
},
|
||||||
|
recorder.Stages);
|
||||||
|
|
||||||
|
// And exactly one InboundRequest/Delivered audit row was emitted —
|
||||||
|
// proving the audit middleware actually wrapped the endpoint.
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.Equal(AuditKind.InboundRequest, evt.Kind);
|
||||||
|
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
||||||
|
Assert.Equal("test-actor", evt.Actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Middleware_Pipeline_Records_AuthFailure_Beyond_Endpoint()
|
||||||
|
{
|
||||||
|
// A 401 short-circuit happens *inside the endpoint handler* in the real
|
||||||
|
// InboundAPI (the X-API-Key validator runs there), so the audit
|
||||||
|
// middleware still wraps it and observes the 401 response status. This
|
||||||
|
// confirms ordering supports the InboundAuthFailure emission path.
|
||||||
|
var recorder = new OrderingRecorder();
|
||||||
|
var writer = new RecordingAuditWriter();
|
||||||
|
|
||||||
|
using var host = await BuildHostAsync(recorder, writer, endpointStatus: 401);
|
||||||
|
var client = host.GetTestClient();
|
||||||
|
|
||||||
|
var response = await client.PostAsync("/api/echo", new StringContent("{}"));
|
||||||
|
|
||||||
|
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.Equal(AuditKind.InboundAuthFailure, evt.Kind);
|
||||||
|
Assert.Equal(AuditStatus.Failed, evt.Status);
|
||||||
|
Assert.Equal(401, evt.HttpStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a minimal in-memory host whose pipeline mirrors the production
|
||||||
|
/// arrangement in <c>ScadaLink.Host.Program.cs</c>:
|
||||||
|
/// <c>UseRouting → UseAuthentication → UseAuthorization → UseAuditWriteMiddleware → endpoints</c>.
|
||||||
|
/// Marker middlewares record their entry into <paramref name="recorder"/> so
|
||||||
|
/// the test can assert on the resulting ordering.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<IHost> BuildHostAsync(
|
||||||
|
OrderingRecorder recorder,
|
||||||
|
ICentralAuditWriter writer,
|
||||||
|
int endpointStatus = 200)
|
||||||
|
{
|
||||||
|
var hostBuilder = new HostBuilder()
|
||||||
|
.ConfigureWebHost(webBuilder =>
|
||||||
|
{
|
||||||
|
webBuilder
|
||||||
|
.UseTestServer()
|
||||||
|
.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
services.AddSingleton(writer);
|
||||||
|
services.AddSingleton<AuditWriteMiddleware>(sp =>
|
||||||
|
new AuditWriteMiddleware(
|
||||||
|
// The middleware factory pattern is bypassed
|
||||||
|
// here so the inner delegate is closed over the
|
||||||
|
// recorder — UseMiddleware<T> below still
|
||||||
|
// instantiates the type correctly.
|
||||||
|
_ => Task.CompletedTask,
|
||||||
|
writer,
|
||||||
|
NullLogger<AuditWriteMiddleware>.Instance));
|
||||||
|
services.AddRouting();
|
||||||
|
services.AddAuthorization();
|
||||||
|
services.AddAuthentication("TestScheme")
|
||||||
|
.AddScheme<AuthenticationSchemeOptions, AlwaysAuthenticatedHandler>(
|
||||||
|
"TestScheme", _ => { });
|
||||||
|
})
|
||||||
|
.Configure(app =>
|
||||||
|
{
|
||||||
|
app.UseRouting();
|
||||||
|
app.Use(async (ctx, next) =>
|
||||||
|
{
|
||||||
|
recorder.Record("auth");
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.Use(async (ctx, next) =>
|
||||||
|
{
|
||||||
|
recorder.Record("authz");
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
// The order-under-test: AuditWriteMiddleware sits
|
||||||
|
// AFTER auth/authz markers and BEFORE the endpoint
|
||||||
|
// marker. We wrap with a sentinel marker that fires
|
||||||
|
// *before* the audit middleware enters so the test can
|
||||||
|
// pin where the audit middleware lands in the chain.
|
||||||
|
app.Use(async (ctx, next) =>
|
||||||
|
{
|
||||||
|
recorder.Record("audit-before");
|
||||||
|
await next();
|
||||||
|
recorder.Record("audit-after");
|
||||||
|
});
|
||||||
|
app.UseAuditWriteMiddleware();
|
||||||
|
|
||||||
|
app.UseEndpoints(endpoints =>
|
||||||
|
{
|
||||||
|
endpoints.MapPost("/api/{methodName}", async ctx =>
|
||||||
|
{
|
||||||
|
recorder.Record("endpoint");
|
||||||
|
|
||||||
|
if (endpointStatus is 401 or 403)
|
||||||
|
{
|
||||||
|
// Simulate an auth-failure short-circuit
|
||||||
|
// produced by the in-handler API key
|
||||||
|
// validator — Actor must stay null.
|
||||||
|
ctx.Response.StatusCode = endpointStatus;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate the production handler stashing
|
||||||
|
// the resolved API key name AFTER auth.
|
||||||
|
ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "test-actor";
|
||||||
|
ctx.Response.StatusCode = endpointStatus;
|
||||||
|
await ctx.Response.WriteAsync("ok");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var host = await hostBuilder.StartAsync();
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal authentication handler that always succeeds — keeps
|
||||||
|
/// <see cref="HttpContext.User"/> populated so the test's audit middleware
|
||||||
|
/// path that prefers Items but falls back to User.Identity has a real
|
||||||
|
/// principal to ignore. The middleware's primary path uses Items so this
|
||||||
|
/// handler's claim never appears on the emitted Actor.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class AlwaysAuthenticatedHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||||
|
{
|
||||||
|
public AlwaysAuthenticatedHandler(
|
||||||
|
Microsoft.Extensions.Options.IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||||
|
Microsoft.Extensions.Logging.ILoggerFactory logger,
|
||||||
|
System.Text.Encodings.Web.UrlEncoder encoder)
|
||||||
|
: base(options, logger, encoder) { }
|
||||||
|
|
||||||
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "framework-user") }, "TestScheme");
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
var ticket = new AuthenticationTicket(principal, "TestScheme");
|
||||||
|
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
<PackageReference Include="NSubstitute" />
|
<PackageReference Include="NSubstitute" />
|
||||||
<PackageReference Include="xunit" />
|
<PackageReference Include="xunit" />
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Entities.Notifications;
|
using ScadaLink.Commons.Entities.Notifications;
|
||||||
using ScadaLink.Commons.Interfaces.Repositories;
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
using ScadaLink.Commons.Messages.Notification;
|
using ScadaLink.Commons.Messages.Notification;
|
||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
using ScadaLink.ConfigurationDatabase;
|
using ScadaLink.ConfigurationDatabase;
|
||||||
@@ -172,7 +174,20 @@ public class NotificationOutboxFlowTests : TestKit
|
|||||||
};
|
};
|
||||||
|
|
||||||
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||||
services, options, NullLogger<NotificationOutboxActor>.Instance)));
|
services,
|
||||||
|
options,
|
||||||
|
(ICentralAuditWriter)new NoOpCentralAuditWriter(),
|
||||||
|
NullLogger<NotificationOutboxActor>.Instance)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test-only no-op <see cref="ICentralAuditWriter"/>. The integration tests
|
||||||
|
/// in this file pre-date M4 Bundle B's audit-writer injection; they do not
|
||||||
|
/// assert on emission, just need a non-null collaborator.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class NoOpCentralAuditWriter : ICentralAuditWriter
|
||||||
|
{
|
||||||
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static NotificationSubmit MakeSubmit(string notificationId)
|
private static NotificationSubmit MakeSubmit(string notificationId)
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NSubstitute;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Entities.Notifications;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.NotificationOutbox.Delivery;
|
||||||
|
using ScadaLink.NotificationOutbox.Messages;
|
||||||
|
|
||||||
|
namespace ScadaLink.NotificationOutbox.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M4 Bundle B (B2) — verifies the <see cref="NotificationOutboxActor"/>
|
||||||
|
/// dispatcher loop emits exactly ONE
|
||||||
|
/// <see cref="AuditChannel.Notification"/>/<see cref="AuditKind.NotifyDeliver"/>
|
||||||
|
/// audit row with <see cref="AuditStatus.Attempted"/> per attempt regardless of
|
||||||
|
/// the delivery outcome (success, transient, permanent). Terminal-state
|
||||||
|
/// emission is covered separately in
|
||||||
|
/// <see cref="NotificationOutboxActorTerminalEmissionTests"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class NotificationOutboxActorAttemptEmissionTests : TestKit
|
||||||
|
{
|
||||||
|
private readonly INotificationOutboxRepository _outboxRepository =
|
||||||
|
Substitute.For<INotificationOutboxRepository>();
|
||||||
|
|
||||||
|
private readonly INotificationRepository _notificationRepository =
|
||||||
|
Substitute.For<INotificationRepository>();
|
||||||
|
|
||||||
|
private readonly RecordingCentralAuditWriter _auditWriter = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recording writer so each test can assert on the events captured during
|
||||||
|
/// one dispatch tick without depending on a concrete implementation.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class RecordingCentralAuditWriter : ICentralAuditWriter
|
||||||
|
{
|
||||||
|
public List<AuditEvent> Events { get; } = new();
|
||||||
|
public Func<AuditEvent, Task>? OnWrite { get; set; }
|
||||||
|
|
||||||
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
lock (Events)
|
||||||
|
{
|
||||||
|
Events.Add(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return OnWrite?.Invoke(evt) ?? Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IServiceProvider BuildServiceProvider(IEnumerable<INotificationDeliveryAdapter> adapters)
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddScoped(_ => _outboxRepository);
|
||||||
|
services.AddScoped(_ => _notificationRepository);
|
||||||
|
foreach (var adapter in adapters)
|
||||||
|
{
|
||||||
|
services.AddScoped<INotificationDeliveryAdapter>(_ => adapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubAdapter : INotificationDeliveryAdapter
|
||||||
|
{
|
||||||
|
private readonly Func<DeliveryOutcome> _outcome;
|
||||||
|
public int CallCount;
|
||||||
|
|
||||||
|
public StubAdapter(Func<DeliveryOutcome> outcome) { _outcome = outcome; }
|
||||||
|
|
||||||
|
public NotificationType Type => NotificationType.Email;
|
||||||
|
|
||||||
|
public Task<DeliveryOutcome> DeliverAsync(
|
||||||
|
Notification notification, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref CallCount);
|
||||||
|
return Task.FromResult(_outcome());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IActorRef CreateActor(IEnumerable<INotificationDeliveryAdapter> adapters)
|
||||||
|
{
|
||||||
|
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||||
|
BuildServiceProvider(adapters),
|
||||||
|
new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
|
||||||
|
(ICentralAuditWriter)_auditWriter,
|
||||||
|
NullLogger<NotificationOutboxActor>.Instance)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Notification MakeNotification(
|
||||||
|
Guid? notificationId = null,
|
||||||
|
string sourceSite = "site-1",
|
||||||
|
int retryCount = 0)
|
||||||
|
{
|
||||||
|
return new Notification(
|
||||||
|
(notificationId ?? Guid.NewGuid()).ToString("D"),
|
||||||
|
NotificationType.Email,
|
||||||
|
"ops-team",
|
||||||
|
"Tank overflow",
|
||||||
|
"Tank 3 level critical",
|
||||||
|
sourceSite)
|
||||||
|
{
|
||||||
|
RetryCount = retryCount,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
SourceInstanceId = "instance-42",
|
||||||
|
SourceScript = "AlarmScript",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetupSmtpRetryPolicy(int maxRetries, TimeSpan retryDelay)
|
||||||
|
{
|
||||||
|
var config = new SmtpConfiguration("smtp.example.com", "Basic", "noreply@example.com")
|
||||||
|
{
|
||||||
|
MaxRetries = maxRetries,
|
||||||
|
RetryDelay = retryDelay,
|
||||||
|
};
|
||||||
|
_notificationRepository.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { config });
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<AuditEvent> EventsByStatus(AuditStatus status)
|
||||||
|
{
|
||||||
|
lock (_auditWriter.Events)
|
||||||
|
{
|
||||||
|
return _auditWriter.Events.Where(e => e.Status == status).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Attempt_Success_EmitsOneEvent_KindNotifyDeliver_StatusAttempted()
|
||||||
|
{
|
||||||
|
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var notification = MakeNotification(notificationId: id, sourceSite: "site-alpha");
|
||||||
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { notification });
|
||||||
|
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
|
||||||
|
var actor = CreateActor([adapter]);
|
||||||
|
|
||||||
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
var attempted = EventsByStatus(AuditStatus.Attempted);
|
||||||
|
Assert.Single(attempted);
|
||||||
|
var evt = attempted[0];
|
||||||
|
Assert.Equal(AuditChannel.Notification, evt.Channel);
|
||||||
|
Assert.Equal(AuditKind.NotifyDeliver, evt.Kind);
|
||||||
|
Assert.Equal(id, evt.CorrelationId);
|
||||||
|
Assert.Equal("ops-team", evt.Target);
|
||||||
|
Assert.Equal("site-alpha", evt.SourceSiteId);
|
||||||
|
Assert.Equal("instance-42", evt.SourceInstanceId);
|
||||||
|
Assert.Equal("AlarmScript", evt.SourceScript);
|
||||||
|
// Central dispatch: actor is null (no authenticated end-user).
|
||||||
|
Assert.Null(evt.Actor);
|
||||||
|
// Successful attempt: no error message.
|
||||||
|
Assert.Null(evt.ErrorMessage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Attempt_TransientFailure_EmitsEvent_StatusAttempted_ErrorMessageSet()
|
||||||
|
{
|
||||||
|
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||||
|
var notification = MakeNotification(retryCount: 1);
|
||||||
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { notification });
|
||||||
|
var adapter = new StubAdapter(() => DeliveryOutcome.Transient("smtp timeout"));
|
||||||
|
var actor = CreateActor([adapter]);
|
||||||
|
|
||||||
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
var attempted = EventsByStatus(AuditStatus.Attempted);
|
||||||
|
Assert.Single(attempted);
|
||||||
|
Assert.Equal(AuditKind.NotifyDeliver, attempted[0].Kind);
|
||||||
|
Assert.Equal("smtp timeout", attempted[0].ErrorMessage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Attempt_PermanentFailure_EmitsEvent_StatusAttempted_ErrorMessageSet()
|
||||||
|
{
|
||||||
|
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||||
|
var notification = MakeNotification();
|
||||||
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { notification });
|
||||||
|
var adapter = new StubAdapter(() => DeliveryOutcome.Permanent("invalid recipient address"));
|
||||||
|
var actor = CreateActor([adapter]);
|
||||||
|
|
||||||
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
var attempted = EventsByStatus(AuditStatus.Attempted);
|
||||||
|
Assert.Single(attempted);
|
||||||
|
Assert.Equal(AuditKind.NotifyDeliver, attempted[0].Kind);
|
||||||
|
Assert.Equal("invalid recipient address", attempted[0].ErrorMessage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AuditWriter_Throws_DeliveryStateUpdate_StillSucceeds()
|
||||||
|
{
|
||||||
|
// Audit failure must NEVER abort the user-facing action: the delivery
|
||||||
|
// outcome must still be persisted via UpdateAsync.
|
||||||
|
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||||
|
var notification = MakeNotification();
|
||||||
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { notification });
|
||||||
|
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
|
||||||
|
_auditWriter.OnWrite = _ => throw new InvalidOperationException("audit dead");
|
||||||
|
var actor = CreateActor([adapter]);
|
||||||
|
|
||||||
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
|
||||||
|
// Update of the notification row must still happen.
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
_outboxRepository.Received(1).UpdateAsync(
|
||||||
|
Arg.Is<Notification>(n => n.Status == NotificationStatus.Delivered),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Attempt_RecordsOccurredAtUtc_AsUtc()
|
||||||
|
{
|
||||||
|
// The OccurredAtUtc on the emitted event must be UTC (all timestamps
|
||||||
|
// are UTC throughout the system).
|
||||||
|
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||||
|
var notification = MakeNotification();
|
||||||
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { notification });
|
||||||
|
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
|
||||||
|
var actor = CreateActor([adapter]);
|
||||||
|
|
||||||
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
var attempted = EventsByStatus(AuditStatus.Attempted);
|
||||||
|
Assert.Single(attempted);
|
||||||
|
Assert.Equal(DateTimeKind.Utc, attempted[0].OccurredAtUtc.Kind);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NSubstitute;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Entities.Notifications;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
|
||||||
|
namespace ScadaLink.NotificationOutbox.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M4 Bundle B (B1) — verifies <see cref="NotificationOutboxActor"/> accepts an
|
||||||
|
/// <see cref="ICentralAuditWriter"/> at construction so subsequent bundle tasks
|
||||||
|
/// (B2/B3) can route attempt + terminal lifecycle events through the central
|
||||||
|
/// direct-write audit path.
|
||||||
|
/// </summary>
|
||||||
|
public class NotificationOutboxActorAuditInjectionTests : TestKit
|
||||||
|
{
|
||||||
|
private static IServiceProvider BuildEmptyProvider()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddScoped(_ => Substitute.For<INotificationOutboxRepository>());
|
||||||
|
services.AddScoped(_ => Substitute.For<INotificationRepository>());
|
||||||
|
return services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inline NoOp writer that records calls — used to assert later tasks emit
|
||||||
|
/// events without depending on a concrete CentralAuditWriter.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class RecordingCentralAuditWriter : ICentralAuditWriter
|
||||||
|
{
|
||||||
|
public List<AuditEvent> Events { get; } = new();
|
||||||
|
|
||||||
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
lock (Events)
|
||||||
|
{
|
||||||
|
Events.Add(evt);
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Actor_ConstructedWith_ICentralAuditWriter_NoException()
|
||||||
|
{
|
||||||
|
var writer = new RecordingCentralAuditWriter();
|
||||||
|
// Long dispatch interval so PreStart's timer never fires during the test.
|
||||||
|
var options = new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) };
|
||||||
|
|
||||||
|
var actor = Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||||
|
BuildEmptyProvider(),
|
||||||
|
options,
|
||||||
|
writer,
|
||||||
|
NullLogger<NotificationOutboxActor>.Instance)));
|
||||||
|
|
||||||
|
Assert.NotNull(actor);
|
||||||
|
// No event has been emitted yet — the writer is purely injected at this stage.
|
||||||
|
lock (writer.Events)
|
||||||
|
{
|
||||||
|
Assert.Empty(writer.Events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Actor_NullAuditWriter_Throws()
|
||||||
|
{
|
||||||
|
var options = new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) };
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentNullException>(() => new NotificationOutboxActor(
|
||||||
|
BuildEmptyProvider(),
|
||||||
|
options,
|
||||||
|
auditWriter: null!,
|
||||||
|
NullLogger<NotificationOutboxActor>.Instance));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ using ScadaLink.Commons.Interfaces.Repositories;
|
|||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
using ScadaLink.NotificationOutbox.Delivery;
|
using ScadaLink.NotificationOutbox.Delivery;
|
||||||
using ScadaLink.NotificationOutbox.Messages;
|
using ScadaLink.NotificationOutbox.Messages;
|
||||||
|
using ScadaLink.NotificationOutbox.Tests.TestSupport;
|
||||||
|
|
||||||
namespace ScadaLink.NotificationOutbox.Tests;
|
namespace ScadaLink.NotificationOutbox.Tests;
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
|||||||
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||||
BuildServiceProvider(adapters),
|
BuildServiceProvider(adapters),
|
||||||
options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
|
options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
|
||||||
|
new NoOpCentralAuditWriter(),
|
||||||
NullLogger<NotificationOutboxActor>.Instance)));
|
NullLogger<NotificationOutboxActor>.Instance)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using ScadaLink.Commons.Interfaces.Repositories;
|
|||||||
using ScadaLink.Commons.Messages.Notification;
|
using ScadaLink.Commons.Messages.Notification;
|
||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
using ScadaLink.NotificationOutbox.Delivery;
|
using ScadaLink.NotificationOutbox.Delivery;
|
||||||
|
using ScadaLink.NotificationOutbox.Tests.TestSupport;
|
||||||
|
|
||||||
namespace ScadaLink.NotificationOutbox.Tests;
|
namespace ScadaLink.NotificationOutbox.Tests;
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ public class NotificationOutboxActorIngestTests : TestKit
|
|||||||
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||||
BuildServiceProvider(),
|
BuildServiceProvider(),
|
||||||
new NotificationOutboxOptions(),
|
new NotificationOutboxOptions(),
|
||||||
|
new NoOpCentralAuditWriter(),
|
||||||
NullLogger<NotificationOutboxActor>.Instance)));
|
NullLogger<NotificationOutboxActor>.Instance)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using ScadaLink.Commons.Interfaces.Repositories;
|
|||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
using ScadaLink.NotificationOutbox.Delivery;
|
using ScadaLink.NotificationOutbox.Delivery;
|
||||||
using ScadaLink.NotificationOutbox.Messages;
|
using ScadaLink.NotificationOutbox.Messages;
|
||||||
|
using ScadaLink.NotificationOutbox.Tests.TestSupport;
|
||||||
|
|
||||||
namespace ScadaLink.NotificationOutbox.Tests;
|
namespace ScadaLink.NotificationOutbox.Tests;
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ public class NotificationOutboxActorPurgeTests : TestKit
|
|||||||
DispatchInterval = TimeSpan.FromHours(1),
|
DispatchInterval = TimeSpan.FromHours(1),
|
||||||
PurgeInterval = TimeSpan.FromHours(1),
|
PurgeInterval = TimeSpan.FromHours(1),
|
||||||
},
|
},
|
||||||
|
new NoOpCentralAuditWriter(),
|
||||||
NullLogger<NotificationOutboxActor>.Instance)));
|
NullLogger<NotificationOutboxActor>.Instance)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using ScadaLink.Commons.Messages.Notification;
|
|||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
using ScadaLink.Commons.Types.Notifications;
|
using ScadaLink.Commons.Types.Notifications;
|
||||||
using ScadaLink.NotificationOutbox.Delivery;
|
using ScadaLink.NotificationOutbox.Delivery;
|
||||||
|
using ScadaLink.NotificationOutbox.Tests.TestSupport;
|
||||||
|
|
||||||
namespace ScadaLink.NotificationOutbox.Tests;
|
namespace ScadaLink.NotificationOutbox.Tests;
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
|||||||
BuildServiceProvider(),
|
BuildServiceProvider(),
|
||||||
// A long dispatch interval keeps the dispatch loop from interfering with these tests.
|
// A long dispatch interval keeps the dispatch loop from interfering with these tests.
|
||||||
options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
|
options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
|
||||||
|
new NoOpCentralAuditWriter(),
|
||||||
NullLogger<NotificationOutboxActor>.Instance)));
|
NullLogger<NotificationOutboxActor>.Instance)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NSubstitute;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Entities.Notifications;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.Commons.Messages.Notification;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.NotificationOutbox.Delivery;
|
||||||
|
using ScadaLink.NotificationOutbox.Messages;
|
||||||
|
|
||||||
|
namespace ScadaLink.NotificationOutbox.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M4 Bundle B (B3) — verifies the <see cref="NotificationOutboxActor"/>
|
||||||
|
/// emits a second
|
||||||
|
/// <see cref="AuditChannel.Notification"/>/<see cref="AuditKind.NotifyDeliver"/>
|
||||||
|
/// audit row carrying the terminal status (Delivered, Parked, Discarded) on
|
||||||
|
/// every terminal-state transition. The B2 Attempted row is still emitted
|
||||||
|
/// alongside the terminal one — these tests assert ONLY the terminal row
|
||||||
|
/// presence and status.
|
||||||
|
/// </summary>
|
||||||
|
public class NotificationOutboxActorTerminalEmissionTests : TestKit
|
||||||
|
{
|
||||||
|
private readonly INotificationOutboxRepository _outboxRepository =
|
||||||
|
Substitute.For<INotificationOutboxRepository>();
|
||||||
|
|
||||||
|
private readonly INotificationRepository _notificationRepository =
|
||||||
|
Substitute.For<INotificationRepository>();
|
||||||
|
|
||||||
|
private readonly RecordingCentralAuditWriter _auditWriter = new();
|
||||||
|
|
||||||
|
private sealed class RecordingCentralAuditWriter : ICentralAuditWriter
|
||||||
|
{
|
||||||
|
public List<AuditEvent> Events { get; } = new();
|
||||||
|
public Func<AuditEvent, Task>? OnWrite { get; set; }
|
||||||
|
|
||||||
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
lock (Events)
|
||||||
|
{
|
||||||
|
Events.Add(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return OnWrite?.Invoke(evt) ?? Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IServiceProvider BuildServiceProvider(IEnumerable<INotificationDeliveryAdapter> adapters)
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddScoped(_ => _outboxRepository);
|
||||||
|
services.AddScoped(_ => _notificationRepository);
|
||||||
|
foreach (var adapter in adapters)
|
||||||
|
{
|
||||||
|
services.AddScoped<INotificationDeliveryAdapter>(_ => adapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubAdapter : INotificationDeliveryAdapter
|
||||||
|
{
|
||||||
|
private readonly Func<DeliveryOutcome> _outcome;
|
||||||
|
|
||||||
|
public StubAdapter(Func<DeliveryOutcome> outcome) { _outcome = outcome; }
|
||||||
|
|
||||||
|
public NotificationType Type => NotificationType.Email;
|
||||||
|
|
||||||
|
public Task<DeliveryOutcome> DeliverAsync(
|
||||||
|
Notification notification, CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(_outcome());
|
||||||
|
}
|
||||||
|
|
||||||
|
private IActorRef CreateActor(IEnumerable<INotificationDeliveryAdapter> adapters)
|
||||||
|
{
|
||||||
|
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||||
|
BuildServiceProvider(adapters),
|
||||||
|
new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
|
||||||
|
(ICentralAuditWriter)_auditWriter,
|
||||||
|
NullLogger<NotificationOutboxActor>.Instance)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Notification MakeNotification(
|
||||||
|
NotificationStatus status = NotificationStatus.Pending,
|
||||||
|
int retryCount = 0,
|
||||||
|
Guid? notificationId = null)
|
||||||
|
{
|
||||||
|
return new Notification(
|
||||||
|
(notificationId ?? Guid.NewGuid()).ToString("D"),
|
||||||
|
NotificationType.Email,
|
||||||
|
"ops-team",
|
||||||
|
"Tank overflow",
|
||||||
|
"Tank 3 level critical",
|
||||||
|
"site-1")
|
||||||
|
{
|
||||||
|
Status = status,
|
||||||
|
RetryCount = retryCount,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetupSmtpRetryPolicy(int maxRetries, TimeSpan retryDelay)
|
||||||
|
{
|
||||||
|
var config = new SmtpConfiguration("smtp.example.com", "Basic", "noreply@example.com")
|
||||||
|
{
|
||||||
|
MaxRetries = maxRetries,
|
||||||
|
RetryDelay = retryDelay,
|
||||||
|
};
|
||||||
|
_notificationRepository.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { config });
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<AuditEvent> EventsByStatus(AuditStatus status)
|
||||||
|
{
|
||||||
|
lock (_auditWriter.Events)
|
||||||
|
{
|
||||||
|
return _auditWriter.Events.Where(e => e.Status == status).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Terminal_Delivered_EmitsEvent_StatusDelivered()
|
||||||
|
{
|
||||||
|
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||||
|
var notification = MakeNotification();
|
||||||
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { notification });
|
||||||
|
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
|
||||||
|
var actor = CreateActor([adapter]);
|
||||||
|
|
||||||
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
var delivered = EventsByStatus(AuditStatus.Delivered);
|
||||||
|
Assert.Single(delivered);
|
||||||
|
var evt = delivered[0];
|
||||||
|
Assert.Equal(AuditChannel.Notification, evt.Channel);
|
||||||
|
Assert.Equal(AuditKind.NotifyDeliver, evt.Kind);
|
||||||
|
Assert.Equal("ops-team", evt.Target);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Terminal_Parked_OnPermanentFailure_EmitsEvent_StatusParked()
|
||||||
|
{
|
||||||
|
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||||
|
var notification = MakeNotification();
|
||||||
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { notification });
|
||||||
|
var adapter = new StubAdapter(() => DeliveryOutcome.Permanent("invalid recipient address"));
|
||||||
|
var actor = CreateActor([adapter]);
|
||||||
|
|
||||||
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
var parked = EventsByStatus(AuditStatus.Parked);
|
||||||
|
Assert.Single(parked);
|
||||||
|
Assert.Equal(AuditKind.NotifyDeliver, parked[0].Kind);
|
||||||
|
Assert.Equal("invalid recipient address", parked[0].ErrorMessage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Terminal_Parked_OnTransientReachingMaxRetries_EmitsEvent_StatusParked()
|
||||||
|
{
|
||||||
|
SetupSmtpRetryPolicy(maxRetries: 3, retryDelay: TimeSpan.FromMinutes(1));
|
||||||
|
// RetryCount starts at max-1; the failed attempt increments it to max
|
||||||
|
// which triggers the Parked terminal transition.
|
||||||
|
var notification = MakeNotification(retryCount: 2);
|
||||||
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { notification });
|
||||||
|
var adapter = new StubAdapter(() => DeliveryOutcome.Transient("smtp timeout"));
|
||||||
|
var actor = CreateActor([adapter]);
|
||||||
|
|
||||||
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
var parked = EventsByStatus(AuditStatus.Parked);
|
||||||
|
Assert.Single(parked);
|
||||||
|
Assert.Equal(AuditKind.NotifyDeliver, parked[0].Kind);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Terminal_Parked_OnMissingAdapter_EmitsEvent_StatusParked()
|
||||||
|
{
|
||||||
|
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||||
|
var notification = MakeNotification();
|
||||||
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { notification });
|
||||||
|
// No adapters registered: the missing-adapter park path runs.
|
||||||
|
var actor = CreateActor([]);
|
||||||
|
|
||||||
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
var parked = EventsByStatus(AuditStatus.Parked);
|
||||||
|
Assert.Single(parked);
|
||||||
|
Assert.Equal(AuditKind.NotifyDeliver, parked[0].Kind);
|
||||||
|
Assert.Contains("no delivery adapter", parked[0].ErrorMessage!);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Transient_BelowMaxRetries_DoesNotEmitTerminalRow()
|
||||||
|
{
|
||||||
|
// A transient failure that does not reach max-retries leaves the row
|
||||||
|
// in Retrying — non-terminal, so no terminal audit row should be
|
||||||
|
// emitted (only the Attempted row from B2).
|
||||||
|
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||||
|
var notification = MakeNotification(retryCount: 0);
|
||||||
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { notification });
|
||||||
|
var adapter = new StubAdapter(() => DeliveryOutcome.Transient("smtp timeout"));
|
||||||
|
var actor = CreateActor([adapter]);
|
||||||
|
|
||||||
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
|
||||||
|
// Wait for the Attempted row to land so we know dispatch has run.
|
||||||
|
AwaitAssert(() => Assert.Single(EventsByStatus(AuditStatus.Attempted)));
|
||||||
|
|
||||||
|
// No terminal rows of any kind.
|
||||||
|
Assert.Empty(EventsByStatus(AuditStatus.Delivered));
|
||||||
|
Assert.Empty(EventsByStatus(AuditStatus.Parked));
|
||||||
|
Assert.Empty(EventsByStatus(AuditStatus.Discarded));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Terminal_Discarded_OnManualDiscard_EmitsEvent_StatusDiscarded()
|
||||||
|
{
|
||||||
|
// Wire the actor with a parked row that GetByIdAsync returns; the
|
||||||
|
// discard handler must emit a terminal Discarded audit row.
|
||||||
|
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||||
|
var notification = MakeNotification(status: NotificationStatus.Parked);
|
||||||
|
_outboxRepository.GetByIdAsync(notification.NotificationId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(notification);
|
||||||
|
var actor = CreateActor([]);
|
||||||
|
|
||||||
|
actor.Tell(new DiscardNotificationRequest(
|
||||||
|
CorrelationId: "test-corr", NotificationId: notification.NotificationId));
|
||||||
|
|
||||||
|
// First wait for the discard handler to reply (handshake), then assert
|
||||||
|
// the audit row landed.
|
||||||
|
ExpectMsg<DiscardNotificationResponse>(r => r.Success);
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
var discarded = EventsByStatus(AuditStatus.Discarded);
|
||||||
|
Assert.Single(discarded);
|
||||||
|
Assert.Equal(AuditKind.NotifyDeliver, discarded[0].Kind);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AuditWriter_Throws_TerminalUpdate_StillSucceeds()
|
||||||
|
{
|
||||||
|
// Audit failure NEVER aborts the user-facing action: the dispatcher
|
||||||
|
// must still persist the Delivered status via UpdateAsync.
|
||||||
|
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
|
||||||
|
var notification = MakeNotification();
|
||||||
|
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { notification });
|
||||||
|
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
|
||||||
|
_auditWriter.OnWrite = _ => throw new InvalidOperationException("audit dead");
|
||||||
|
var actor = CreateActor([adapter]);
|
||||||
|
|
||||||
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||||
|
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
_outboxRepository.Received(1).UpdateAsync(
|
||||||
|
Arg.Is<Notification>(n => n.Status == NotificationStatus.Delivered),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
|
||||||
|
namespace ScadaLink.NotificationOutbox.Tests.TestSupport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test-only no-op <see cref="ICentralAuditWriter"/>. Used by existing
|
||||||
|
/// NotificationOutboxActor TestKit fixtures whose tests pre-date the M4 Bundle B
|
||||||
|
/// audit-writer injection — they don't care about audit emission, they just
|
||||||
|
/// need a non-null collaborator so the actor's constructor succeeds.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class NoOpCentralAuditWriter : ICentralAuditWriter
|
||||||
|
{
|
||||||
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.SiteRuntime.Scripts;
|
||||||
|
|
||||||
|
namespace ScadaLink.SiteRuntime.Tests.Scripts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 — M4 Bundle A (Tasks A1+A2): every synchronous DB call made
|
||||||
|
/// through <c>Database.Connection("name")</c> emits exactly one
|
||||||
|
/// <c>DbOutbound</c>/<c>DbWrite</c> audit event with an <c>Extra</c> envelope
|
||||||
|
/// distinguishing writes (<c>op="write"</c>, <c>rowsAffected=N</c>) from reads
|
||||||
|
/// (<c>op="read"</c>, <c>rowsReturned=N</c>). The audit emission is
|
||||||
|
/// best-effort — a thrown <see cref="IAuditWriter.WriteAsync"/> must never
|
||||||
|
/// abort the script's call, and the original ADO.NET result (or original
|
||||||
|
/// exception) must surface to the caller unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public class DatabaseSyncEmissionTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory <see cref="IAuditWriter"/> mirroring the M2 Bundle F stub —
|
||||||
|
/// captures every event and may be configured to throw to verify the
|
||||||
|
/// 3-layer fail-safe (mirrors <c>CapturingAuditWriter</c> in
|
||||||
|
/// <c>ExternalSystemCallAuditEmissionTests</c>).
|
||||||
|
/// </summary>
|
||||||
|
private sealed class CapturingAuditWriter : IAuditWriter
|
||||||
|
{
|
||||||
|
public List<AuditEvent> Events { get; } = new();
|
||||||
|
public Exception? ThrowOnWrite { get; set; }
|
||||||
|
|
||||||
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (ThrowOnWrite != null)
|
||||||
|
{
|
||||||
|
return Task.FromException(ThrowOnWrite);
|
||||||
|
}
|
||||||
|
|
||||||
|
Events.Add(evt);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string SiteId = "site-77";
|
||||||
|
private const string InstanceName = "Plant.Pump42";
|
||||||
|
private const string SourceScript = "ScriptActor:Sync";
|
||||||
|
private const string ConnectionName = "machineData";
|
||||||
|
|
||||||
|
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
|
||||||
|
IDatabaseGateway gateway,
|
||||||
|
IAuditWriter? auditWriter)
|
||||||
|
{
|
||||||
|
return new ScriptRuntimeContext.DatabaseHelper(
|
||||||
|
gateway,
|
||||||
|
InstanceName,
|
||||||
|
NullLogger.Instance,
|
||||||
|
auditWriter: auditWriter,
|
||||||
|
siteId: SiteId,
|
||||||
|
sourceScript: SourceScript,
|
||||||
|
cachedForwarder: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spin up a fresh in-memory SQLite database with a tiny single-table
|
||||||
|
/// schema we can write to and read from. The connection is returned in
|
||||||
|
/// the open state so the test only has to call <c>Connection()</c> via
|
||||||
|
/// the helper. SQLite in-memory databases live as long as the connection
|
||||||
|
/// holding them, so the keep-alive root must outlive any auditing
|
||||||
|
/// wrapper the test exercises.
|
||||||
|
/// </summary>
|
||||||
|
private static SqliteConnection NewInMemoryDb(out SqliteConnection keepAlive)
|
||||||
|
{
|
||||||
|
// The shared-cache name is per-test (Guid) so concurrent tests don't
|
||||||
|
// collide. mode=memory keeps it RAM-only; cache=shared lets the
|
||||||
|
// keep-alive root and the gateway-returned connection see the same
|
||||||
|
// in-memory DB. The keepAlive connection must remain open for the
|
||||||
|
// duration of the test or the in-memory DB is discarded.
|
||||||
|
var dbName = $"db-{Guid.NewGuid():N}";
|
||||||
|
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
|
||||||
|
|
||||||
|
keepAlive = new SqliteConnection(connStr);
|
||||||
|
keepAlive.Open();
|
||||||
|
using (var seed = keepAlive.CreateCommand())
|
||||||
|
{
|
||||||
|
seed.CommandText =
|
||||||
|
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);" +
|
||||||
|
"INSERT INTO t (id, name) VALUES (1, 'alpha');" +
|
||||||
|
"INSERT INTO t (id, name) VALUES (2, 'beta');";
|
||||||
|
seed.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
var live = new SqliteConnection(connStr);
|
||||||
|
live.Open();
|
||||||
|
return live;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Execute_InsertSuccess_EmitsOneEvent_KindDbWrite_StatusDelivered_OpWrite_RowsAffected()
|
||||||
|
{
|
||||||
|
using var keepAlive = new SqliteConnection("Data Source=k;Mode=Memory;Cache=Shared");
|
||||||
|
var inner = NewInMemoryDb(out var _);
|
||||||
|
var gateway = new Mock<IDatabaseGateway>();
|
||||||
|
gateway
|
||||||
|
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(inner);
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
|
||||||
|
var helper = CreateHelper(gateway.Object, writer);
|
||||||
|
await using var conn = await helper.Connection(ConnectionName);
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "INSERT INTO t (id, name) VALUES (3, 'gamma')";
|
||||||
|
var rows = await cmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1, rows);
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
|
||||||
|
Assert.Equal(AuditKind.DbWrite, evt.Kind);
|
||||||
|
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
||||||
|
Assert.Equal(AuditForwardState.Pending, evt.ForwardState);
|
||||||
|
Assert.NotNull(evt.Extra);
|
||||||
|
Assert.Contains("\"op\":\"write\"", evt.Extra);
|
||||||
|
Assert.Contains("\"rowsAffected\":1", evt.Extra);
|
||||||
|
Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind);
|
||||||
|
Assert.NotEqual(Guid.Empty, evt.EventId);
|
||||||
|
Assert.StartsWith(ConnectionName, evt.Target);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteScalar_Success_EmitsKindDbWrite_OpWrite()
|
||||||
|
{
|
||||||
|
using var keepAlive = new SqliteConnection("Data Source=k2;Mode=Memory;Cache=Shared");
|
||||||
|
var inner = NewInMemoryDb(out var _);
|
||||||
|
var gateway = new Mock<IDatabaseGateway>();
|
||||||
|
gateway
|
||||||
|
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(inner);
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
|
||||||
|
var helper = CreateHelper(gateway.Object, writer);
|
||||||
|
await using var conn = await helper.Connection(ConnectionName);
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT COUNT(*) FROM t";
|
||||||
|
var scalar = await cmd.ExecuteScalarAsync();
|
||||||
|
|
||||||
|
Assert.NotNull(scalar);
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
|
||||||
|
Assert.Equal(AuditKind.DbWrite, evt.Kind);
|
||||||
|
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
||||||
|
Assert.NotNull(evt.Extra);
|
||||||
|
// ExecuteScalar is classified as "write" per the M4 vocabulary lock
|
||||||
|
// (Channel=DbOutbound, Kind=DbWrite, Extra.op="write") — the
|
||||||
|
// rowsAffected for a SELECT-on-SqlCommand is -1 in ADO.NET; the audit
|
||||||
|
// wrapper records whatever DbCommand.ExecuteScalar returned via the
|
||||||
|
// built-in path, plus the rowsAffected counter the wrapper observed.
|
||||||
|
Assert.Contains("\"op\":\"write\"", evt.Extra);
|
||||||
|
Assert.Contains("rowsAffected", evt.Extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Execute_Throws_EmitsEvent_StatusFailed_ErrorMessageSet()
|
||||||
|
{
|
||||||
|
using var keepAlive = new SqliteConnection("Data Source=k3;Mode=Memory;Cache=Shared");
|
||||||
|
var inner = NewInMemoryDb(out var _);
|
||||||
|
var gateway = new Mock<IDatabaseGateway>();
|
||||||
|
gateway
|
||||||
|
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(inner);
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
|
||||||
|
var helper = CreateHelper(gateway.Object, writer);
|
||||||
|
await using var conn = await helper.Connection(ConnectionName);
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
// Reference an undefined column — SQLite throws SqliteException synchronously.
|
||||||
|
cmd.CommandText = "INSERT INTO t (does_not_exist) VALUES (1)";
|
||||||
|
await Assert.ThrowsAsync<SqliteException>(() => cmd.ExecuteNonQueryAsync());
|
||||||
|
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.Equal(AuditStatus.Failed, evt.Status);
|
||||||
|
Assert.False(string.IsNullOrEmpty(evt.ErrorMessage));
|
||||||
|
Assert.NotNull(evt.ErrorDetail);
|
||||||
|
Assert.Contains("does_not_exist", evt.ErrorDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteReader_Success_EmitsKindDbWrite_OpRead_RowsReturned()
|
||||||
|
{
|
||||||
|
using var keepAlive = new SqliteConnection("Data Source=k4;Mode=Memory;Cache=Shared");
|
||||||
|
var inner = NewInMemoryDb(out var _);
|
||||||
|
var gateway = new Mock<IDatabaseGateway>();
|
||||||
|
gateway
|
||||||
|
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(inner);
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
|
||||||
|
var helper = CreateHelper(gateway.Object, writer);
|
||||||
|
await using var conn = await helper.Connection(ConnectionName);
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT id, name FROM t ORDER BY id";
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync();
|
||||||
|
var rows = 0;
|
||||||
|
while (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
rows++;
|
||||||
|
}
|
||||||
|
// Close the reader explicitly so the audit emission (deferred to
|
||||||
|
// reader-close per the wrapper contract) fires before assertion.
|
||||||
|
await reader.CloseAsync();
|
||||||
|
|
||||||
|
Assert.Equal(2, rows);
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
|
||||||
|
Assert.Equal(AuditKind.DbWrite, evt.Kind);
|
||||||
|
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
||||||
|
Assert.NotNull(evt.Extra);
|
||||||
|
Assert.Contains("\"op\":\"read\"", evt.Extra);
|
||||||
|
Assert.Contains("\"rowsReturned\":2", evt.Extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuditWriter_Throws_ScriptCall_ReturnsOriginalResult()
|
||||||
|
{
|
||||||
|
using var keepAlive = new SqliteConnection("Data Source=k5;Mode=Memory;Cache=Shared");
|
||||||
|
var inner = NewInMemoryDb(out var _);
|
||||||
|
var gateway = new Mock<IDatabaseGateway>();
|
||||||
|
gateway
|
||||||
|
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(inner);
|
||||||
|
var writer = new CapturingAuditWriter
|
||||||
|
{
|
||||||
|
ThrowOnWrite = new InvalidOperationException("audit writer down")
|
||||||
|
};
|
||||||
|
|
||||||
|
var helper = CreateHelper(gateway.Object, writer);
|
||||||
|
await using var conn = await helper.Connection(ConnectionName);
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "INSERT INTO t (id, name) VALUES (4, 'delta')";
|
||||||
|
var rows = await cmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
// Original ADO.NET result must surface unchanged despite the audit
|
||||||
|
// writer faulting — the wrapper swallows + logs the audit failure.
|
||||||
|
Assert.Equal(1, rows);
|
||||||
|
Assert.Empty(writer.Events);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Provenance_PopulatedFromContext()
|
||||||
|
{
|
||||||
|
using var keepAlive = new SqliteConnection("Data Source=k6;Mode=Memory;Cache=Shared");
|
||||||
|
var inner = NewInMemoryDb(out var _);
|
||||||
|
var gateway = new Mock<IDatabaseGateway>();
|
||||||
|
gateway
|
||||||
|
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(inner);
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
|
||||||
|
var helper = CreateHelper(gateway.Object, writer);
|
||||||
|
await using var conn = await helper.Connection(ConnectionName);
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "INSERT INTO t (id, name) VALUES (5, 'epsilon')";
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.Equal(SiteId, evt.SourceSiteId);
|
||||||
|
Assert.Equal(InstanceName, evt.SourceInstanceId);
|
||||||
|
Assert.Equal(SourceScript, evt.SourceScript);
|
||||||
|
Assert.Null(evt.Actor);
|
||||||
|
Assert.Null(evt.CorrelationId);
|
||||||
|
Assert.NotEqual(Guid.Empty, evt.EventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DurationMs_NonZero()
|
||||||
|
{
|
||||||
|
using var keepAlive = new SqliteConnection("Data Source=k7;Mode=Memory;Cache=Shared");
|
||||||
|
var inner = NewInMemoryDb(out var _);
|
||||||
|
var gateway = new Mock<IDatabaseGateway>();
|
||||||
|
gateway
|
||||||
|
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(inner);
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
|
||||||
|
var helper = CreateHelper(gateway.Object, writer);
|
||||||
|
await using var conn = await helper.Connection(ConnectionName);
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "INSERT INTO t (id, name) VALUES (6, 'zeta')";
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.NotNull(evt.DurationMs);
|
||||||
|
Assert.True(evt.DurationMs >= 0, $"DurationMs={evt.DurationMs} should be >= 0");
|
||||||
|
Assert.True(evt.DurationMs <= 5000, $"DurationMs={evt.DurationMs} should be <= 5000");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Akka.Actor;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.SiteRuntime.Scripts;
|
||||||
|
using ScadaLink.StoreAndForward;
|
||||||
|
|
||||||
|
namespace ScadaLink.SiteRuntime.Tests.Scripts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 — M4 Bundle C (Task C1): every script-initiated
|
||||||
|
/// <c>Notify.To("list").Send(...)</c> emits exactly one
|
||||||
|
/// <c>Notification</c>/<c>NotifySend</c> audit event via the wrapper inside
|
||||||
|
/// <see cref="ScriptRuntimeContext.NotifyTarget"/>. The audit emission is
|
||||||
|
/// best-effort: a thrown <see cref="IAuditWriter.WriteAsync"/> must never
|
||||||
|
/// abort the script's <c>Send</c> — the original <c>NotificationId</c> must
|
||||||
|
/// still flow back to the caller and the underlying S&F enqueue must still
|
||||||
|
/// have happened.
|
||||||
|
/// </summary>
|
||||||
|
public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory <see cref="IAuditWriter"/> that records every event passed to
|
||||||
|
/// <see cref="WriteAsync"/>. Optionally configurable to throw, simulating a
|
||||||
|
/// catastrophic audit-writer failure that the wrapper must swallow per
|
||||||
|
/// alog.md §7.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class CapturingAuditWriter : IAuditWriter
|
||||||
|
{
|
||||||
|
public List<AuditEvent> Events { get; } = new();
|
||||||
|
public Exception? ThrowOnWrite { get; set; }
|
||||||
|
|
||||||
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (ThrowOnWrite != null)
|
||||||
|
{
|
||||||
|
return Task.FromException(ThrowOnWrite);
|
||||||
|
}
|
||||||
|
|
||||||
|
Events.Add(evt);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string SiteId = "site-7";
|
||||||
|
private const string InstanceName = "Plant.Pump3";
|
||||||
|
private const string SourceScript = "ScriptActor:CheckPressure";
|
||||||
|
private const string ListName = "Operators";
|
||||||
|
private const string Subject = "Pump alarm";
|
||||||
|
private const string Body = "Pump 3 tripped";
|
||||||
|
|
||||||
|
private readonly SqliteConnection _keepAlive;
|
||||||
|
private readonly StoreAndForwardStorage _storage;
|
||||||
|
private readonly StoreAndForwardService _saf;
|
||||||
|
|
||||||
|
public NotifySendAuditEmissionTests()
|
||||||
|
{
|
||||||
|
var dbName = $"NotifySendAudit_{Guid.NewGuid():N}";
|
||||||
|
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
|
||||||
|
_keepAlive = new SqliteConnection(connStr);
|
||||||
|
_keepAlive.Open();
|
||||||
|
|
||||||
|
_storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
|
||||||
|
var options = new StoreAndForwardOptions
|
||||||
|
{
|
||||||
|
DefaultRetryInterval = TimeSpan.Zero,
|
||||||
|
DefaultMaxRetries = 3,
|
||||||
|
RetryTimerInterval = TimeSpan.FromMinutes(10)
|
||||||
|
};
|
||||||
|
_saf = new StoreAndForwardService(_storage, options, NullLogger<StoreAndForwardService>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync() => await _storage.InitializeAsync();
|
||||||
|
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
_keepAlive.Dispose();
|
||||||
|
}
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ScriptRuntimeContext.NotifyHelper CreateHelper(
|
||||||
|
IAuditWriter? auditWriter,
|
||||||
|
string? sourceScript = SourceScript)
|
||||||
|
{
|
||||||
|
// siteCommunicationActor is unused by Send — pass a probe so the helper
|
||||||
|
// is fully constructed.
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
return new ScriptRuntimeContext.NotifyHelper(
|
||||||
|
_saf,
|
||||||
|
probe.Ref,
|
||||||
|
SiteId,
|
||||||
|
InstanceName,
|
||||||
|
sourceScript,
|
||||||
|
TimeSpan.FromSeconds(3),
|
||||||
|
NullLogger.Instance,
|
||||||
|
auditWriter);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Send_Success_EmitsOneEvent_KindNotifySend_StatusSubmitted()
|
||||||
|
{
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
var notify = CreateHelper(writer);
|
||||||
|
|
||||||
|
var notificationId = await notify.To(ListName).Send(Subject, Body);
|
||||||
|
|
||||||
|
Assert.False(string.IsNullOrEmpty(notificationId));
|
||||||
|
Assert.Single(writer.Events);
|
||||||
|
var evt = writer.Events[0];
|
||||||
|
Assert.Equal(AuditChannel.Notification, evt.Channel);
|
||||||
|
Assert.Equal(AuditKind.NotifySend, evt.Kind);
|
||||||
|
Assert.Equal(AuditStatus.Submitted, evt.Status);
|
||||||
|
Assert.Equal(AuditForwardState.Pending, evt.ForwardState);
|
||||||
|
Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind);
|
||||||
|
Assert.NotEqual(Guid.Empty, evt.EventId);
|
||||||
|
Assert.False(evt.PayloadTruncated);
|
||||||
|
Assert.Null(evt.DurationMs);
|
||||||
|
Assert.Null(evt.HttpStatus);
|
||||||
|
Assert.Null(evt.ErrorMessage);
|
||||||
|
Assert.Null(evt.ErrorDetail);
|
||||||
|
Assert.Null(evt.Actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Send_PopulatesTarget_AsListName()
|
||||||
|
{
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
var notify = CreateHelper(writer);
|
||||||
|
|
||||||
|
await notify.To(ListName).Send(Subject, Body);
|
||||||
|
|
||||||
|
var evt = writer.Events[0];
|
||||||
|
Assert.Equal(ListName, evt.Target);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Send_PopulatesRequestSummary_AsSubjectBodyJson()
|
||||||
|
{
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
var notify = CreateHelper(writer);
|
||||||
|
|
||||||
|
await notify.To(ListName).Send(Subject, Body);
|
||||||
|
|
||||||
|
var evt = writer.Events[0];
|
||||||
|
Assert.NotNull(evt.RequestSummary);
|
||||||
|
// Round-trip the JSON to assert the exact shape, not raw text — the
|
||||||
|
// contract is "JSON of {subject, body}", which downstream redaction
|
||||||
|
// (M5) can reshape; M4 captures verbatim.
|
||||||
|
using var doc = JsonDocument.Parse(evt.RequestSummary!);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
Assert.Equal(JsonValueKind.Object, root.ValueKind);
|
||||||
|
Assert.Equal(Subject, root.GetProperty("subject").GetString());
|
||||||
|
Assert.Equal(Body, root.GetProperty("body").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Send_AuditWriter_Throws_OriginalSendStillReturns()
|
||||||
|
{
|
||||||
|
var writer = new CapturingAuditWriter
|
||||||
|
{
|
||||||
|
ThrowOnWrite = new InvalidOperationException("audit writer down")
|
||||||
|
};
|
||||||
|
var notify = CreateHelper(writer);
|
||||||
|
|
||||||
|
// The Send call must NOT bubble the audit-writer failure: the script
|
||||||
|
// contract is that the notification is buffered and the id is returned
|
||||||
|
// even when the audit pipeline is sick.
|
||||||
|
var notificationId = await notify.To(ListName).Send(Subject, Body);
|
||||||
|
|
||||||
|
Assert.False(string.IsNullOrEmpty(notificationId));
|
||||||
|
|
||||||
|
// And the underlying S&F enqueue must still have happened — audit is
|
||||||
|
// purely additive, never aborts the user-facing action.
|
||||||
|
var buffered = await _saf.GetMessageByIdAsync(notificationId);
|
||||||
|
Assert.NotNull(buffered);
|
||||||
|
Assert.Equal(notificationId, buffered!.Id);
|
||||||
|
|
||||||
|
Assert.Empty(writer.Events);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Send_Provenance_PopulatedFromContext()
|
||||||
|
{
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
var notify = CreateHelper(writer);
|
||||||
|
|
||||||
|
await notify.To(ListName).Send(Subject, Body);
|
||||||
|
|
||||||
|
var evt = writer.Events[0];
|
||||||
|
Assert.Equal(SiteId, evt.SourceSiteId);
|
||||||
|
Assert.Equal(InstanceName, evt.SourceInstanceId);
|
||||||
|
Assert.Equal(SourceScript, evt.SourceScript);
|
||||||
|
Assert.Null(evt.Actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Send_NotificationIdParsed_AsCorrelationId()
|
||||||
|
{
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
var notify = CreateHelper(writer);
|
||||||
|
|
||||||
|
var notificationId = await notify.To(ListName).Send(Subject, Body);
|
||||||
|
|
||||||
|
// NotificationId is minted as Guid.NewGuid().ToString("N") — the 32-char
|
||||||
|
// hex form, which Guid.TryParse accepts. The audit row's CorrelationId
|
||||||
|
// must round-trip back to the same Guid value.
|
||||||
|
Assert.True(Guid.TryParse(notificationId, out var expected),
|
||||||
|
$"NotificationId '{notificationId}' should be a parseable Guid");
|
||||||
|
var evt = writer.Events[0];
|
||||||
|
Assert.NotNull(evt.CorrelationId);
|
||||||
|
Assert.Equal(expected, evt.CorrelationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Send_WithoutAuditWriter_StillReturnsNotificationId_AndEnqueues()
|
||||||
|
{
|
||||||
|
// Audit is opt-in (mirrors M2 Bundle F behaviour): a null writer must
|
||||||
|
// degrade to a no-op audit path so tests / minimal hosts that don't
|
||||||
|
// wire AddAuditLog still work.
|
||||||
|
var notify = CreateHelper(auditWriter: null);
|
||||||
|
|
||||||
|
var notificationId = await notify.To(ListName).Send(Subject, Body);
|
||||||
|
|
||||||
|
Assert.False(string.IsNullOrEmpty(notificationId));
|
||||||
|
var buffered = await _saf.GetMessageByIdAsync(notificationId);
|
||||||
|
Assert.NotNull(buffered);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user