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.EntityFrameworkCore" 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.CodeAnalysis.CSharp.Scripting" 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.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ScadaLink.AuditLog.Central;
|
||||
using ScadaLink.AuditLog.Configuration;
|
||||
using ScadaLink.AuditLog.Site;
|
||||
using ScadaLink.AuditLog.Site.Telemetry;
|
||||
@@ -129,6 +130,17 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<ICachedCallLifecycleObserver>(
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -275,11 +275,18 @@ akka {{
|
||||
.GetRequiredService<IOptions<ScadaLink.NotificationOutbox.NotificationOutboxOptions>>().Value;
|
||||
var outboxLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
|
||||
.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(
|
||||
singletonProps: Props.Create(() => new ScadaLink.NotificationOutbox.NotificationOutboxActor(
|
||||
_serviceProvider,
|
||||
outboxOptions,
|
||||
outboxAuditWriter,
|
||||
outboxLogger)),
|
||||
terminationMessage: PoisonPill.Instance,
|
||||
settings: ClusterSingletonManagerSettings.Create(_actorSystem!)
|
||||
|
||||
@@ -12,6 +12,7 @@ using ScadaLink.Host;
|
||||
using ScadaLink.Host.Actors;
|
||||
using ScadaLink.Host.Health;
|
||||
using ScadaLink.InboundAPI;
|
||||
using ScadaLink.InboundAPI.Middleware;
|
||||
using ScadaLink.ManagementService;
|
||||
using ScadaLink.NotificationOutbox;
|
||||
using ScadaLink.NotificationService;
|
||||
@@ -162,6 +163,18 @@ try
|
||||
app.UseAuthorization();
|
||||
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.
|
||||
// REQ-HOST-4a defines readiness as cluster membership + DB connectivity,
|
||||
// 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.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ScadaLink.InboundAPI.Middleware;
|
||||
|
||||
namespace ScadaLink.InboundAPI;
|
||||
|
||||
@@ -53,6 +54,13 @@ public static class EndpointExtensions
|
||||
|
||||
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
|
||||
JsonElement? body = null;
|
||||
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 Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
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.Commons.Types.Notifications;
|
||||
@@ -30,6 +32,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly NotificationOutboxOptions _options;
|
||||
private readonly ICentralAuditWriter _auditWriter;
|
||||
private readonly ILogger<NotificationOutboxActor> _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -45,11 +48,13 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
public NotificationOutboxActor(
|
||||
IServiceProvider serviceProvider,
|
||||
NotificationOutboxOptions options,
|
||||
ICentralAuditWriter auditWriter,
|
||||
ILogger<NotificationOutboxActor> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
Receive<NotificationSubmit>(HandleSubmit);
|
||||
Receive<InternalMessages.IngestPersisted>(HandleIngestPersisted);
|
||||
@@ -265,6 +270,26 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
/// status transition. A missing adapter parks the notification; otherwise the
|
||||
/// <see cref="DeliveryOutcome"/> drives the transition. The updated row is always persisted.
|
||||
/// </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(
|
||||
Notification notification,
|
||||
DateTimeOffset now,
|
||||
@@ -275,14 +300,29 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
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.LastError = $"no delivery adapter for type {notification.Type}";
|
||||
notification.LastError = missingAdapterError;
|
||||
notification.LastAttemptAt = now;
|
||||
await outboxRepository.UpdateAsync(notification);
|
||||
EmitAttemptAudit(
|
||||
notification,
|
||||
now,
|
||||
durationMs: 0,
|
||||
errorMessage: missingAdapterError);
|
||||
EmitTerminalAudit(notification, now, errorMessage: missingAdapterError);
|
||||
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 durationMs = (int)Math.Min(
|
||||
int.MaxValue, Math.Max(0, (DateTimeOffset.UtcNow - attemptStart).TotalMilliseconds));
|
||||
|
||||
switch (outcome.Result)
|
||||
{
|
||||
@@ -317,6 +357,158 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -563,6 +755,13 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
notification.Status = NotificationStatus.Discarded;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,13 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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>
|
||||
|
||||
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)
|
||||
/// </summary>
|
||||
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
|
||||
// every Database.CachedWrite enqueue.
|
||||
_cachedForwarder);
|
||||
@@ -263,8 +272,16 @@ public class ScriptRuntimeContext
|
||||
/// for central delivery and returns its <c>NotificationId</c>;
|
||||
/// <c>Notify.Status(id)</c> queries the delivery status of that notification.
|
||||
/// </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(
|
||||
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger);
|
||||
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger,
|
||||
_auditWriter);
|
||||
|
||||
/// <summary>
|
||||
/// 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 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(
|
||||
IDatabaseGateway? gateway,
|
||||
string instanceName,
|
||||
ILogger logger,
|
||||
IAuditWriter? auditWriter = null,
|
||||
string siteId = "",
|
||||
string? sourceScript = null,
|
||||
ICachedCallTelemetryForwarder? cachedForwarder = null)
|
||||
@@ -905,6 +935,7 @@ public class ScriptRuntimeContext
|
||||
_gateway = gateway;
|
||||
_instanceName = instanceName;
|
||||
_logger = logger;
|
||||
_auditWriter = auditWriter;
|
||||
_siteId = siteId;
|
||||
_sourceScript = sourceScript;
|
||||
_cachedForwarder = cachedForwarder;
|
||||
@@ -917,7 +948,28 @@ public class ScriptRuntimeContext
|
||||
if (_gateway == null)
|
||||
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>
|
||||
@@ -1046,6 +1098,16 @@ public class ScriptRuntimeContext
|
||||
private readonly TimeSpan _askTimeout;
|
||||
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(
|
||||
StoreAndForwardService? storeAndForward,
|
||||
ICanTell? siteCommunicationActor,
|
||||
@@ -1053,7 +1115,8 @@ public class ScriptRuntimeContext
|
||||
string instanceName,
|
||||
string? sourceScript,
|
||||
TimeSpan askTimeout,
|
||||
ILogger logger)
|
||||
ILogger logger,
|
||||
IAuditWriter? auditWriter = null)
|
||||
{
|
||||
_storeAndForward = storeAndForward;
|
||||
_siteCommunicationActor = siteCommunicationActor;
|
||||
@@ -1062,6 +1125,7 @@ public class ScriptRuntimeContext
|
||||
_sourceScript = sourceScript;
|
||||
_askTimeout = askTimeout;
|
||||
_logger = logger;
|
||||
_auditWriter = auditWriter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1070,7 +1134,10 @@ public class ScriptRuntimeContext
|
||||
public NotifyTarget To(string listName)
|
||||
{
|
||||
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>
|
||||
@@ -1145,13 +1212,22 @@ public class ScriptRuntimeContext
|
||||
private readonly string? _sourceScript;
|
||||
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(
|
||||
string listName,
|
||||
StoreAndForwardService? storeAndForward,
|
||||
string siteId,
|
||||
string instanceName,
|
||||
string? sourceScript,
|
||||
ILogger logger)
|
||||
ILogger logger,
|
||||
IAuditWriter? auditWriter = null)
|
||||
{
|
||||
_listName = listName;
|
||||
_storeAndForward = storeAndForward;
|
||||
@@ -1159,6 +1235,7 @@ public class ScriptRuntimeContext
|
||||
_instanceName = instanceName;
|
||||
_sourceScript = sourceScript;
|
||||
_logger = logger;
|
||||
_auditWriter = auditWriter;
|
||||
}
|
||||
|
||||
/// <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 NotificationId so the buffer can be queried by it (Notify.Status) and
|
||||
// the forwarder's idempotency key matches the buffered row.
|
||||
var occurredAtUtc = DateTime.UtcNow;
|
||||
await _storeAndForward.EnqueueAsync(
|
||||
StoreAndForwardCategory.Notification,
|
||||
target: _listName,
|
||||
@@ -1218,8 +1296,125 @@ public class ScriptRuntimeContext
|
||||
"Notify enqueued notification {NotificationId} to list '{List}' for central delivery",
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ScadaLink.AuditLog.Central;
|
||||
using ScadaLink.AuditLog.Configuration;
|
||||
using ScadaLink.AuditLog.Site;
|
||||
using ScadaLink.AuditLog.Site.Telemetry;
|
||||
@@ -155,6 +156,34 @@ public class AddAuditLogTests
|
||||
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]
|
||||
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.Sqlite" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<!--
|
||||
M4 Bundle E (Task E3): Microsoft.Extensions.Configuration.Json,
|
||||
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="NSubstitute" />
|
||||
<PackageReference Include="xunit" />
|
||||
@@ -55,6 +59,26 @@
|
||||
needs a project reference to SiteRuntime where the store lives.
|
||||
-->
|
||||
<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>
|
||||
|
||||
</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>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="xunit" />
|
||||
|
||||
@@ -8,8 +8,10 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
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.ConfigurationDatabase;
|
||||
@@ -172,7 +174,20 @@ public class NotificationOutboxFlowTests : TestKit
|
||||
};
|
||||
|
||||
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)
|
||||
|
||||
@@ -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.NotificationOutbox.Delivery;
|
||||
using ScadaLink.NotificationOutbox.Messages;
|
||||
using ScadaLink.NotificationOutbox.Tests.TestSupport;
|
||||
|
||||
namespace ScadaLink.NotificationOutbox.Tests;
|
||||
|
||||
@@ -81,6 +82,7 @@ public class NotificationOutboxActorDispatchTests : TestKit
|
||||
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||
BuildServiceProvider(adapters),
|
||||
options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
|
||||
new NoOpCentralAuditWriter(),
|
||||
NullLogger<NotificationOutboxActor>.Instance)));
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Messages.Notification;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.NotificationOutbox.Delivery;
|
||||
using ScadaLink.NotificationOutbox.Tests.TestSupport;
|
||||
|
||||
namespace ScadaLink.NotificationOutbox.Tests;
|
||||
|
||||
@@ -34,6 +35,7 @@ public class NotificationOutboxActorIngestTests : TestKit
|
||||
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
||||
BuildServiceProvider(),
|
||||
new NotificationOutboxOptions(),
|
||||
new NoOpCentralAuditWriter(),
|
||||
NullLogger<NotificationOutboxActor>.Instance)));
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.NotificationOutbox.Delivery;
|
||||
using ScadaLink.NotificationOutbox.Messages;
|
||||
using ScadaLink.NotificationOutbox.Tests.TestSupport;
|
||||
|
||||
namespace ScadaLink.NotificationOutbox.Tests;
|
||||
|
||||
@@ -46,6 +47,7 @@ public class NotificationOutboxActorPurgeTests : TestKit
|
||||
DispatchInterval = TimeSpan.FromHours(1),
|
||||
PurgeInterval = TimeSpan.FromHours(1),
|
||||
},
|
||||
new NoOpCentralAuditWriter(),
|
||||
NullLogger<NotificationOutboxActor>.Instance)));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ using ScadaLink.Commons.Messages.Notification;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.Commons.Types.Notifications;
|
||||
using ScadaLink.NotificationOutbox.Delivery;
|
||||
using ScadaLink.NotificationOutbox.Tests.TestSupport;
|
||||
|
||||
namespace ScadaLink.NotificationOutbox.Tests;
|
||||
|
||||
@@ -36,6 +37,7 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
BuildServiceProvider(),
|
||||
// A long dispatch interval keeps the dispatch loop from interfering with these tests.
|
||||
options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
|
||||
new NoOpCentralAuditWriter(),
|
||||
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