diff --git a/docs/plans/2026-05-20-auditlog-m2-site-sync-pipeline.md b/docs/plans/2026-05-20-auditlog-m2-site-sync-pipeline.md new file mode 100644 index 0000000..62e9c3f --- /dev/null +++ b/docs/plans/2026-05-20-auditlog-m2-site-sync-pipeline.md @@ -0,0 +1,408 @@ +# Audit Log #23 — M2 Site Pipeline (sync-only) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (bundled cadence per `feedback_subagent_cadence`). + +**Goal:** First end-to-end audit emission. A script-initiated `ExternalSystem.Call()` produces exactly one `ApiOutbound`/`ApiCall` row in the central `AuditLog` table via site SQLite hot-path + gRPC push telemetry + central ingest actor. Audit-write failures NEVER abort the script. + +**Architecture (decisions locked):** +- Provenance: **Wrap CallAsync in ScriptRuntimeContext** — IExternalSystemClient.CallAsync signature unchanged; ScriptRuntimeContext.ExternalSystem.Call captures instance/script/site and emits the AuditEvent via IAuditWriter. +- Direction: **Push primary** — SiteAuditTelemetryActor batches Pending rows and pushes via a new `IngestAuditEvents` unary gRPC RPC on `sitestream.proto`. Pull (reconciliation) deferred to M6. +- E2E: **Component-level test** via TestKit + MSSQL fixture; stubbed gRPC client forwards directly to the central ingest actor. No expansion of `ScadaLinkWebApplicationFactory`. +- Site writer: **Mirror SiteEventLogger** — `Channel` + background writer Task for sub-ms enqueue durability. + +**M1 realities baked in:** +- Enum vocabulary: `AuditKind.ApiCall` for sync API call; `AuditStatus.Delivered` for success, `AuditStatus.Failed` for HTTP non-2xx (permanent OR transient → both Failed for a sync call; cached path differs in M3). The "Status=Success/TransientFailure/PermanentFailure" wording in the roadmap is stale and must be replaced with the new vocabulary. +- `AuditLogRepository.InsertIfNotExistsAsync` race window — M2 is the first concurrent writer; harden it before AuditLogIngestActor lands. +- Keyset tiebreaker test gap from Bundle D — add a same-OccurredAt test in M2. +- `MsSqlMigrationFixture` reusable as-is; promoted to `[CollectionDefinition]`-shared if multiple test classes need it (defer until actually needed). +- `Xunit.SkippableFact` + `Skip.IfNot(_fixture.Available, _fixture.SkipReason)` for any MSSQL-dependent tests. +- `ScadaLink.AuditLog/Site/` and `ScadaLink.AuditLog/Central/` and `ScadaLink.AuditLog/Telemetry/` subfolders. DI extension `AddAuditLog` is the registration point. + +**Tech stack additions:** +- `Microsoft.Data.Sqlite 10.0.7` (pinned). +- `Akka.TestKit.Xunit2 1.5.62` (pinned). +- `Grpc.Tools` already configured in `ScadaLink.Communication.csproj`. + +--- + +## Bundles + +- **Bundle A — Repo race-fix + tiebreaker test** (M1 realities catch-up). +- **Bundle B — Site SQLite writer + fallback** (M2-T1, T2, T3, T4). +- **Bundle C — gRPC proto + mapper** (M2-T5, T6). +- **Bundle D — Telemetry actor + ingest actor + gRPC handler** (M2-T7, T8). +- **Bundle E — Host wiring** (M2-T9). +- **Bundle F — ESG emission via ScriptRuntimeContext wrapper** (M2-T10). +- **Bundle G — Health metric SiteAuditWriteFailures** (M2-T11). +- **Bundle H — Component-level integration test** (M2-T12). + +Final cross-bundle reviewer pass, then merge + roadmap update. + +--- + +## Bundle A — Repo race-fix + keyset tiebreaker test + +### Task A1: Harden `InsertIfNotExistsAsync` against duplicate-key race + +**Files:** +- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs:30-60` — wrap the `ExecuteSqlInterpolatedAsync` call in a `try/catch Microsoft.Data.SqlClient.SqlException` that swallows error numbers 2601 and 2627 (unique-index violation on `UX_AuditLog_EventId`) and logs at Debug. Other SqlExceptions rethrow. +- Modify: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs` — add: + - `InsertIfNotExistsAsync_ConcurrentDuplicateInserts_ProduceExactlyOneRow` — fire 50 parallel `InsertIfNotExistsAsync` calls with the same `EventId`, assert row count = 1 and no exception escapes. + - `QueryAsync_Keyset_SameOccurredAtUtc_TiebreaksOnEventId` — Bundle D reviewer's deferred recommendation. Insert 4 rows with identical OccurredAtUtc but distinct EventIds; page through them with PageSize=2; assert no overlap, correct count, and that the second page's first row's EventId is strictly less than the first page's last row's EventId. + +**Steps:** +1. Write failing concurrency test. +2. Run: expect SqlException 2601/2627 OR identical-row-count violation. +3. Add try/catch in the repo. +4. Run: pass. +5. Write failing keyset-tiebreaker test. +6. Run: depending on EF Core 10's Guid.CompareTo translation, this may already pass — confirm. +7. If passing, the test locks in the behavior; commit anyway. +8. Commit: `fix(configdb): InsertIfNotExistsAsync swallows duplicate-key races + add keyset tiebreaker test (#23)`. + +**Bundle A acceptance:** All ConfigurationDatabase.Tests still green; 2 new tests pass. + +--- + +## Bundle B — Site SQLite writer + fallback (mirror SiteEventLogger pattern) + +### Task B1: `SqliteAuditWriter` — schema + connection bootstrap + +**Files:** +- Create: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs` — implements `IAuditWriter` per Bundle A's signature (single `Task WriteAsync(AuditEvent evt, CancellationToken ct = default)`). Constructor takes `IOptions` + `ILogger`. Single `SqliteConnection` opened at construction (`Data Source={path};Cache=Shared`). Sync `_writeLock` Monitor-pattern (mirrors `SiteEventLogger.cs:32`). Inline `InitializeSchema()` runs `PRAGMA auto_vacuum = INCREMENTAL` + `CREATE TABLE IF NOT EXISTS AuditLog (...)`. +- Create: `src/ScadaLink.AuditLog/Site/SqliteAuditWriterOptions.cs` — `string DatabasePath = "auditlog.db"`, `int ChannelCapacity = 4096` (bounded; drop-oldest applies in Bundle B-T3 ring overflow, but the writer's pending channel is bounded as a safety net), `int BatchSize = 256`, `int FlushIntervalMs = 50`. +- Create: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs`. + +**Schema (20 site columns + ForwardState — IngestedAtUtc is central-only):** + +```sql +CREATE TABLE IF NOT EXISTS AuditLog ( + EventId TEXT NOT NULL, + OccurredAtUtc TEXT NOT NULL, + Channel TEXT NOT NULL, + Kind TEXT NOT NULL, + CorrelationId TEXT NULL, + SourceSiteId TEXT NULL, + SourceInstanceId TEXT NULL, + SourceScript TEXT NULL, + Actor TEXT NULL, + Target TEXT NULL, + Status TEXT NOT NULL, + HttpStatus INTEGER NULL, + DurationMs INTEGER NULL, + ErrorMessage TEXT NULL, + ErrorDetail TEXT NULL, + RequestSummary TEXT NULL, + ResponseSummary TEXT NULL, + PayloadTruncated INTEGER NOT NULL, + Extra TEXT NULL, + ForwardState TEXT NOT NULL, + PRIMARY KEY (EventId) +); +CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred + ON AuditLog (ForwardState, OccurredAtUtc); +``` + +**Tests:** +1. `Opens_Creates_AuditLog_Table_With_All_Columns_And_PK` +2. `Opens_Creates_IX_ForwardState_Occurred_Index` +3. `PRAGMA_auto_vacuum_Is_INCREMENTAL` + +**Steps:** +1. Failing test asserts table + PK + 20 columns + index via `PRAGMA table_info(AuditLog)` + `PRAGMA index_list(AuditLog)`. +2. Implement constructor + InitializeSchema with inline SQL. +3. Run: pass. +4. Commit: `feat(auditlog): SqliteAuditWriter schema bootstrap (#23)`. + +### Task B2: `SqliteAuditWriter` — Channel + background writer for hot-path + +**Files:** +- Modify: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs` — add `Channel _writeQueue` (bounded BoundedChannelFullMode.Wait, default capacity 4096), background `Task ProcessWriteQueueAsync()` launched in constructor. `WriteAsync` enqueues + returns the pending's `TaskCompletionSource`. The loop reads up to `BatchSize`, opens a transaction, INSERTs all events, commits, completes the TCS for each. +- Pattern mirrors `src/ScadaLink.SiteEventLogging/SiteEventLogger.cs:135-173`. +- Test: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs`. + +**Tests:** +1. `WriteAsync_FreshEvent_PersistsWithForwardStatePending` — write one event, query SQLite, assert row has `ForwardState='Pending'`. +2. `WriteAsync_Concurrent_1000Calls_All_Persist_NoExceptions` — fire 1000 parallel WriteAsync, assert row count = 1000 and zero exceptions surface. +3. `WriteAsync_LatencyP99_LessThan_5ms_For_Enqueue` — assert TCS Task.IsCompleted within reasonable time AFTER awaiting, but the enqueue itself returns near-instantly (verify via a stopwatch around the Channel.Writer.TryWriteAsync). +4. `WriteAsync_DuplicateEventId_FirstWriteWins_NoException` — insert same EventId twice, assert one row only and no exception (the PRIMARY KEY violation is caught/swallowed in the writer loop). + +**Steps:** +1. Failing tests for 1, 2, 4. +2. Implement Channel + background loop + transactional batch INSERT. +3. Run: pass. +4. Commit: `feat(auditlog): SqliteAuditWriter Channel-based hot-path write (#23)`. + +### Task B3: `RingBufferFallback` + +**Files:** +- Create: `src/ScadaLink.AuditLog/Site/RingBufferFallback.cs` — `Channel` bounded at 1024 with `BoundedChannelFullMode.DropOldest`. Exposes `bool TryEnqueue(AuditEvent)`, `IAsyncEnumerable DrainAsync(CancellationToken)`, and an event `RingBufferOverflowed` (callback for the health counter). +- Test: `tests/ScadaLink.AuditLog.Tests/Site/RingBufferFallbackTests.cs`. + +**Tests:** +1. `Enqueue_1025_Into_1024Cap_Ring_DropsOldest_AndRaisesOverflow` — invoke 1025 enqueues, assert the OverflowEvent counter increments once, and the surviving 1024 are the latest. +2. `DrainAsync_Yields_FIFO_Then_Completes_When_Empty`. + +**Steps:** +1. Failing tests. +2. Implement using `Channel.CreateBounded(new BoundedChannelOptions(1024) { FullMode = BoundedChannelFullMode.DropOldest })`. +3. Run: pass. +4. Commit: `feat(auditlog): RingBufferFallback with drop-oldest overflow (#23)`. + +### Task B4: `FallbackAuditWriter` — compose primary + ring + +**Files:** +- Create: `src/ScadaLink.AuditLog/Site/FallbackAuditWriter.cs` — implements `IAuditWriter`. Constructor takes the primary `SqliteAuditWriter` + `RingBufferFallback` + `IAuditWriteFailureCounter` (lightweight DI'd interface, Bundle G implements it as `SiteAuditWriteFailures` counter on health metrics). On primary success: returns. On primary throw: increments counter, enqueues into ring (DropOldest), returns success. On the NEXT successful primary call (success after a failure window), drains the ring back through the primary. +- Test: `tests/ScadaLink.AuditLog.Tests/Site/FallbackAuditWriterTests.cs`. + +**Tests:** +1. `WriteAsync_PrimaryThrows_EventLandsInRing_CallReturnsSuccess`. +2. `WriteAsync_PrimaryRecovers_RingDrains_InFIFOOrder_OnNextWrite`. +3. `WriteAsync_PrimaryAlwaysSucceeds_Ring_StaysEmpty`. + +**Steps:** +1. Failing tests. +2. Implement; mock the primary with a `Func` flip-switch failure. +3. Run: pass. +4. Commit: `feat(auditlog): FallbackAuditWriter compose SQLite + ring (#23)`. + +**Bundle B acceptance:** 4 tasks merged. `ScadaLink.AuditLog.Tests` adds ~12+ tests. No regressions. + +--- + +## Bundle C — gRPC proto + mapper + +### Task C1: Extend `sitestream.proto` with `IngestAuditEvents` + +**Files:** +- Modify: `src/ScadaLink.Communication/Protos/sitestream.proto` — add the messages and unary RPC. Use `google.protobuf.Timestamp` for `OccurredAtUtc`; encode enums as `string` (matches the EF mapping). + +Proposed addition: +```proto +message AuditEventDto { + string event_id = 1; + google.protobuf.Timestamp occurred_at_utc = 2; + string channel = 3; + string kind = 4; + string correlation_id = 5; // empty string when null + string source_site_id = 6; + string source_instance_id = 7; + string source_script = 8; + string actor = 9; + string target = 10; + string status = 11; + google.protobuf.Int32Value http_status = 12; + google.protobuf.Int32Value duration_ms = 13; + string error_message = 14; + string error_detail = 15; + string request_summary = 16; + string response_summary = 17; + bool payload_truncated = 18; + string extra = 19; +} +message AuditEventBatch { repeated AuditEventDto events = 1; } +message IngestAck { repeated string accepted_event_ids = 1; } + +service SiteStreamService { + // existing rpcs... + rpc IngestAuditEvents(AuditEventBatch) returns (IngestAck); +} +``` + +(Use `google.protobuf.Int32Value` to encode nullable ints; empty string semantics for nullable text fields.) + +- Test: `tests/ScadaLink.Communication.Tests/Protos/AuditEventProtoTests.cs`. + +**Steps:** +1. Edit proto + rebuild (`dotnet build src/ScadaLink.Communication/`). +2. Failing test round-trips an `AuditEventDto` through `ToByteArray()` and `Parser.ParseFrom()`; asserts all populated fields survive. +3. Run: pass. +4. Commit: `feat(comms): IngestAuditEvents RPC + AuditEventDto proto (#23)`. + +### Task C2: `AuditEvent` ↔ `AuditEventDto` mapper + +**Files:** +- Create: `src/ScadaLink.AuditLog/Telemetry/AuditEventMapper.cs` — static `ToDto(AuditEvent)` and `FromDto(AuditEventDto)`. Handles nullable→empty-string, Timestamp↔DateTime UTC, enum↔string. ForwardState NOT carried in the proto (site-local only; central never sees it). +- Test: `tests/ScadaLink.AuditLog.Tests/Telemetry/AuditEventMapperTests.cs`. + +**Tests:** +1. `Roundtrip_FullyPopulated_PreservesAllFields`. +2. `Roundtrip_AllNullableFieldsNull_ProducesEmptyDtoFields`. +3. `FromDto_EmptyOptionalString_BecomesNullProperty`. +4. `ToDto_Sets_OccurredAtUtc_As_UtcTimestamp` — Round-trip with `DateTimeKind.Utc` preserved. + +**Steps:** +1. Failing tests. +2. Implement. +3. Run: pass. +4. Commit: `feat(auditlog): AuditEvent ↔ proto mapper (#23)`. + +**Bundle C acceptance:** Communication.Tests + AuditLog.Tests still green; proto rebuilds cleanly. + +--- + +## Bundle D — SiteAuditTelemetryActor + AuditLogIngestActor + gRPC handler + +### Task D1: `SiteAuditTelemetryActor` — drain loop + +**Files:** +- Create: `src/ScadaLink.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs` — `ReceiveActor`. On `Drain`: queries `SqliteAuditWriter.ReadPendingAsync(BatchSize)`, calls `gRPC client.IngestAuditEventsAsync(batch)`, on ack flips returned EventIds to `Forwarded` via `SqliteAuditWriter.MarkForwardedAsync(eventIds)`. Re-schedules `Drain` self-tick: 5s if ≥1 row drained, 30s otherwise. On gRPC error: re-schedule 5s; rows stay Pending. +- Modify: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs` — add `ReadPendingAsync(int limit, CancellationToken)` returning `IReadOnlyList` (with ForwardState=Pending), and `MarkForwardedAsync(IReadOnlyList eventIds, CancellationToken)`. +- Create: `src/ScadaLink.AuditLog/Site/Telemetry/SiteAuditTelemetryOptions.cs` — `BatchSize=256`, `BusyIntervalSeconds=5`, `IdleIntervalSeconds=30`. +- Test: `tests/ScadaLink.AuditLog.Tests/Site/Telemetry/SiteAuditTelemetryActorTests.cs` using `TestKit` + NSubstitute-mocked gRPC client. + +**Tests:** +1. `Drain_With_50PendingRows_Sends_OneBatch_Of_50`. +2. `Drain_Ack_Flips_Rows_To_Forwarded`. +3. `Drain_GrpcThrows_Rows_StayPending_NextTick_Retries`. +4. `Drain_Cadence_5s_AfterNonZero_30s_AfterZero` (via `TestScheduler`). + +**Steps:** +1. Failing tests. +2. Implement. +3. Run: pass. +4. Commit: `feat(auditlog): SiteAuditTelemetryActor drain loop (#23)`. + +### Task D2: `AuditLogIngestActor` + gRPC server handler + +**Files:** +- Create: `src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs` — `ReceiveActor` accepting `IngestAuditEventsCommand(IReadOnlyList events, IActorRef replyTo)`. For each event, calls `IAuditLogRepository.InsertIfNotExistsAsync` (which now swallows duplicates per Bundle A). Sets `IngestedAtUtc = DateTime.UtcNow` before insert (this is the central-side timestamp). Replies with `IngestAck(acceptedEventIds)` — by spec "accepted" includes already-existed rows (idempotent semantics). +- Create: `src/ScadaLink.AuditLog/Central/IngestAuditEventsCommand.cs` (Akka message). +- Create: `src/ScadaLink.AuditLog/Central/IngestAck.cs` (Akka reply). +- Modify: `src/ScadaLink.Communication/SiteStreamGrpc/SiteStreamGrpcServer.cs` — implement `public override async Task IngestAuditEvents(AuditEventBatch request, ServerCallContext context)` — Ask the central `AuditLogIngestActor` proxy with the deserialized batch, await reply, return. +- Modify: `src/ScadaLink.Communication/SiteStreamGrpc/SiteStreamGrpcServer.cs` — add a setter `SetAuditIngestActor(IActorRef)` mirroring how `SetNotificationOutbox` is wired (per recon: Notification Outbox proxy is handed in via `commService?.SetNotificationOutbox(outboxProxy)`). +- Test: `tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs`. +- Test: `tests/ScadaLink.Communication.Tests/SiteStreamIngestAuditEventsTests.cs`. + +**Tests:** +1. `Receive_BatchOf5_Calls_Repo_5Times_Acks_All`. +2. `Receive_BatchWith_AlreadyExistingEvent_AcksAll_NoDoubleInsert` (idempotent). +3. `Receive_RepoThrowsTransient_Replies_AckExcludingFailedEventIds_LogsError` (partial-failure semantics — what gets acked is what was persisted). +4. `Receive_Sets_IngestedAtUtc_Before_Insert`. +5. `gRPC_Handler_Routes_To_Actor_Returns_Reply`. + +**Steps:** +1. Failing tests. +2. Implement actor + gRPC handler. +3. Run: pass. +4. Commit: `feat(auditlog): AuditLogIngestActor + gRPC handler (#23)`. + +**Bundle D acceptance:** New actor + gRPC handler tests all green. + +--- + +## Bundle E — Host wiring (central singleton + site actor + dispatcher) + +### Task E1: Register `AuditLogIngestActor` + `SiteAuditTelemetryActor` + dispatcher + +**Files:** +- Modify: `src/ScadaLink.Host/Actors/AkkaHostedService.cs` — mirror the Notification Outbox pattern (recon report's exact lines 272-295): + - Central role: `AuditLogIngestActor` as `ClusterSingletonManager` (singleton name `"audit-log-ingest"`) + `ClusterSingletonProxy` (`"audit-log-ingest-proxy"`). Hand the proxy to `SiteStreamGrpcServer.SetAuditIngestActor(proxy)`. + - Site role: `SiteAuditTelemetryActor` as a per-site actor (`actorSystem.ActorOf(Props.Create(...)`), bound to the dedicated dispatcher (below). +- Modify: HOCON in `src/ScadaLink.Host/Configuration/` (the existing akka config file) — add: + ``` + audit-telemetry-dispatcher { + type = ForkJoinDispatcher + throughput = 100 + dedicated-thread-pool { thread-count = 2 } + } + ``` + Apply `.WithDispatcher("audit-telemetry-dispatcher")` to `SiteAuditTelemetryActor`'s Props. +- Modify: `src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs:AddAuditLog` — register the SqliteAuditWriter+RingBufferFallback+FallbackAuditWriter chain and the actor factories. +- Test: `tests/ScadaLink.Host.Tests/AkkaHostedServiceAuditWiringTests.cs`. + +**Tests:** +1. `Central_Host_Starts_With_AuditLogIngest_Singleton_Healthy`. +2. `Site_Host_Starts_With_SiteAuditTelemetry_Bound_To_DedicatedDispatcher`. +3. `AuditWriter_Resolves_From_DI_To_FallbackAuditWriter`. + +**Steps:** +1. Failing tests against current host (which doesn't wire audit). +2. Implement wiring. +3. Run: pass. +4. Commit: `feat(host): register Audit Log #23 singletons with dedicated dispatcher`. + +**Bundle E acceptance:** Host.Tests still green; 3 new tests pass. + +--- + +## Bundle F — ESG audit emission via ScriptRuntimeContext wrapper + +### Task F1: Wrap `ExternalSystem.Call` in `ScriptRuntimeContext` to emit audit + +**Files:** +- Modify: `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs` — find the existing `ExternalSystem.Call` method (or add one if scripts call through a dynamic API surface). Inside, after `_externalSystemClient.CallAsync(...)` returns OR throws, build the `AuditEvent` (channel=`ApiOutbound`, kind=`ApiCall`, status=`Delivered` for success, `Failed` for HTTP non-2xx or exception, populate `Target=$"{systemName}.{methodName}"`, `SourceSiteId={siteId}`, `SourceInstanceId={instanceName}`, `SourceScript={sourceScript}`, `DurationMs={stopwatch}`, `HttpStatus`, `ErrorMessage`). Call `_auditWriter.WriteAsync(evt)` inside a try/catch that swallows + logs at Warning + increments `SiteAuditWriteFailures` (via the same counter Bundle G defines). Re-throw the original ExternalSystem exception (if any) so the script sees its original error path unchanged. +- Modify: `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs` constructor — inject `IAuditWriter`. +- Modify: `src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs` — resolve and pass `IAuditWriter` into the ScriptRuntimeContext. +- Test: `tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs`. + +**Tests:** +1. `Call_Success_EmitsOneEvent_Channel_ApiOutbound_Kind_ApiCall_Status_Delivered`. +2. `Call_HTTP500_EmitsEvent_Status_Failed_HttpStatus_500_ErrorMessage_Set`. +3. `Call_HTTP400_EmitsEvent_Status_Failed_HttpStatus_400`. +4. `Call_ClientThrows_NetworkError_EmitsEvent_Status_Failed_ErrorMessage_SetFromException`. +5. `AuditWriter_Throws_Script_Call_Returns_Original_Result_Unchanged_Audit_Failure_Counter_Incremented`. +6. `Provenance_Populated_FromContext` — SourceInstanceId, SourceScript, SourceSiteId all match the ScriptRuntimeContext's values. + +**Steps:** +1. Failing tests. +2. Implement wrapper + provenance threading. +3. Run: pass. +4. Commit: `feat(siteruntime): ExternalSystem.Call emits Audit Log #23 event on every sync call`. + +**Bundle F acceptance:** SiteRuntime.Tests still green; 6 new tests. + +--- + +## Bundle G — Health metric `SiteAuditWriteFailures` + +### Task G1: Counter + DI surface + +**Files:** +- Create: `src/ScadaLink.AuditLog/Site/IAuditWriteFailureCounter.cs` — `void Increment();`. Bundle B's `FallbackAuditWriter` already takes this. +- Modify: `src/ScadaLink.HealthMonitoring/SiteHealthCollector.cs` — add `int _siteAuditWriteFailures` field + `IncrementSiteAuditWriteFailures()` method using `Interlocked.Increment`. Expose via a snapshot read. +- Modify: `src/ScadaLink.HealthMonitoring/SiteHealthState.cs` — add `SiteAuditWriteFailures` property to the report payload. +- Implementation: a small adapter class `HealthMetricsAuditWriteFailureCounter : IAuditWriteFailureCounter` registered in DI that bridges to `ISiteHealthCollector.IncrementSiteAuditWriteFailures()`. +- Test: `tests/ScadaLink.HealthMonitoring.Tests/SiteAuditWriteFailuresMetricTests.cs`. + +**Tests:** +1. `Increment_Three_Times_Counter_Reports_3`. +2. `Report_Payload_Includes_SiteAuditWriteFailures`. + +**Steps:** +1. Failing tests. +2. Implement counter + adapter + DI registration. +3. Run: pass. +4. Commit: `feat(health): SiteAuditWriteFailures counter (#23)`. + +**Bundle G acceptance:** HealthMonitoring.Tests still green; 2 new tests. + +--- + +## Bundle H — Component-level integration test + +### Task H1: End-to-end via TestKit + MSSQL fixture + +**Files:** +- Create: `tests/ScadaLink.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs` — uses `MsSqlMigrationFixture` (the M1 reusable fixture; depend on `Xunit.SkippableFact`): + - Brings up `SqliteAuditWriter` against `:memory:`. + - Brings up `SiteAuditTelemetryActor` via TestKit. + - Brings up `AuditLogIngestActor` via TestKit, configured with the MSSQL `IAuditLogRepository` from M1. + - Stubs the gRPC client by overriding the actor's gRPC dependency with a direct `IActorRef`-backed mock that forwards `IngestAuditEvents` directly to the central actor. + - Writes one `AuditEvent` via the FallbackAuditWriter. + - Drives a `Drain` tick on the telemetry actor. + - Asserts the row appears in the MS SQL `AuditLog` table within 5 seconds via `IAuditLogRepository.QueryAsync`. + +**Steps:** +1. Failing test (telemetry not yet wired). +2. Wire the components together via the test harness. +3. Run: pass. +4. Commit: `test(auditlog): end-to-end sync-call emission via TestKit + MSSQL fixture (#23)`. + +**Bundle H acceptance:** New test passes when MSSQL container is up; skips cleanly when down. + +--- + +## Final cross-bundle review + +After Bundles A–H, dispatch a final reviewer agent with the same template as M1's. Acceptance gate: full `dotnet test ScadaLink.slnx` green. Then merge `--no-ff` with summary; update M3–M8 with M2 realities; status paragraph; proceed to M3. diff --git a/src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs b/src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs new file mode 100644 index 0000000..01496bb --- /dev/null +++ b/src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs @@ -0,0 +1,153 @@ +using Akka.Actor; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Messages.Audit; + +namespace ScadaLink.AuditLog.Central; + +/// +/// Central-side singleton (per Bundle E wiring) that ingests batches of +/// rows pushed from sites via the +/// IngestAuditEvents gRPC RPC. Each row is stamped with the central-side +/// and inserted idempotently via +/// — duplicates are +/// silently swallowed (first-write-wins per Bundle A's hardening). +/// +/// +/// +/// Idempotency is the contract: a row that already exists at central counts +/// as "accepted" for the purposes of the reply, because the storage state is +/// consistent and the site is free to flip its local row to Forwarded. +/// +/// +/// Per Bundle D's brief, audit-write failures must NEVER abort the user-facing +/// action. The actor wraps each repository call in its own try/catch so a +/// single bad row cannot cause the rest of the batch to be lost; the actor's +/// uses Resume so a thrown exception +/// inside ReceiveAsync does not restart the actor (which would also +/// reset any in-flight state). +/// +/// +/// Two constructors exist for a deliberate reason: Bundle D's tests inject a +/// concrete against a per-test MSSQL fixture +/// (the only way to verify the IngestedAtUtc stamp + duplicate-key idempotency +/// end to end), while Bundle E's host wiring registers the actor as a cluster +/// singleton and must therefore resolve the repository — which is a scoped EF +/// Core service — from a fresh DI scope per message. Mirroring the Notification +/// Outbox actor's pattern. +/// +/// +public class AuditLogIngestActor : ReceiveActor +{ + private readonly IServiceProvider? _serviceProvider; + private readonly IAuditLogRepository? _injectedRepository; + private readonly ILogger _logger; + + /// + /// Test-mode constructor — injects a concrete repository instance whose + /// lifetime exceeds the test, so the actor reuses the same instance across + /// every message. Used by Bundle D's MSSQL-backed TestKit fixture. + /// + public AuditLogIngestActor( + IAuditLogRepository repository, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(logger); + + _injectedRepository = repository; + _logger = logger; + + ReceiveAsync(OnIngestAsync); + } + + /// + /// Production constructor — resolves from + /// a fresh DI scope per message because the repository is a scoped EF Core + /// service registered by AddConfigurationDatabase. The actor itself + /// is a long-lived cluster singleton, so it cannot hold a scope across + /// messages. + /// + public AuditLogIngestActor( + IServiceProvider serviceProvider, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + ArgumentNullException.ThrowIfNull(logger); + + _serviceProvider = serviceProvider; + _logger = logger; + + ReceiveAsync(OnIngestAsync); + } + + /// + /// Audit-write failures are best-effort by design (see alog.md §13): a + /// thrown exception in the ingest pipeline must not crash the actor. + /// Resume keeps the actor's state intact so the next batch is processed + /// against the same repository instance. + /// + protected override SupervisorStrategy SupervisorStrategy() + { + return new OneForOneStrategy(maxNrOfRetries: 0, withinTimeRange: TimeSpan.Zero, decider: + Akka.Actor.SupervisorStrategy.DefaultDecider); + } + + private async Task OnIngestAsync(IngestAuditEventsCommand cmd) + { + // Sender is captured before the first await — Akka resets Sender + // between message dispatches, so a post-await Tell would go to + // DeadLetters. + var replyTo = Sender; + var nowUtc = DateTime.UtcNow; + var accepted = new List(cmd.Events.Count); + + // Resolve the repository for the whole batch — one DbContext per + // message, mirroring NotificationOutboxActor. The injected-repository + // mode (Bundle D tests) skips the scope entirely. + IServiceScope? scope = null; + IAuditLogRepository repository; + if (_injectedRepository is not null) + { + repository = _injectedRepository; + } + else + { + scope = _serviceProvider!.CreateScope(); + repository = scope.ServiceProvider.GetRequiredService(); + } + + try + { + foreach (var evt in cmd.Events) + { + try + { + // Stamp IngestedAtUtc here, not at the site. Bundle A's + // repository hardening already swallows duplicate-key races, + // so the same id arriving twice (site retry, reconciliation) + // is a silent no-op. + var ingested = evt with { IngestedAtUtc = nowUtc }; + await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false); + accepted.Add(evt.EventId); + } + catch (Exception ex) + { + // Per-row catch — one bad row never sinks the whole batch. + // The row stays Pending at the site; the next drain retries. + _logger.LogError(ex, + "Failed to persist audit event {EventId} during batch ingest; row will be retried by the site.", + evt.EventId); + } + } + } + finally + { + scope?.Dispose(); + } + + replyTo.Tell(new IngestAuditEventsReply(accepted)); + } +} diff --git a/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj b/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj index 4999344..9641b66 100644 --- a/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj +++ b/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj @@ -8,7 +8,12 @@ + + + + @@ -19,6 +24,8 @@ IAuditLogRepository is registered by ScadaLink.ConfigurationDatabase; the project reference is documented here so M2 writers + telemetry actors can depend on it. --> + + diff --git a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs index 7e010e6..34d3a23 100644 --- a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs @@ -1,44 +1,139 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ScadaLink.AuditLog.Configuration; +using ScadaLink.AuditLog.Site; +using ScadaLink.AuditLog.Site.Telemetry; +using ScadaLink.Commons.Interfaces.Services; namespace ScadaLink.AuditLog; /// -/// Composition root for the Audit Log (#23) component. M1 registers -/// and its validator; later milestones extend -/// this method to wire up writers, telemetry actors, and the central ingest -/// pipeline. Audit Log (#23) sits alongside Notification Outbox (#21) and -/// Site Call Audit (#22). +/// Composition root for the Audit Log (#23) component. /// +/// +/// +/// M1 registered + the validator. M2 Bundle E +/// extends the surface with the site-side writer chain +/// ( + + +/// ) and the telemetry collaborators +/// (, , +/// , , +/// ). +/// +/// +/// Audit Log (#23) sits alongside Notification Outbox (#21) and Site Call +/// Audit (#22). IAuditLogRepository is registered by +/// ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase, +/// so the caller (the Host on the central node) must also call that. +/// +/// public static class ServiceCollectionExtensions { /// Configuration section bound to . public const string ConfigSectionName = "AuditLog"; + /// Configuration section bound to . + public const string SiteWriterSectionName = "AuditLog:SiteWriter"; + + /// Configuration section bound to . + public const string SiteTelemetrySectionName = "AuditLog:SiteTelemetry"; + /// - /// Binds from the - /// section of - /// and registers so a misconfigured - /// AuditLog section is rejected with a key-naming message when the - /// options are first resolved (or at startup when consumers wire in - /// ValidateOnStart()). M2+ will register writers, telemetry actors, - /// and the central ingest pipeline here. IAuditLogRepository is - /// registered by - /// ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase, - /// so the caller (the Host on the central node) must also call that. + /// Registers the Audit Log (#23) component services: options, the site + /// SQLite writer chain (primary + ring fallback + failure-counter sink), + /// and the site-→central telemetry collaborators. Idempotent re-registration + /// is not supported; call this exactly once per . /// public static IServiceCollection AddAuditLog(this IServiceCollection services, IConfiguration config) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(config); + // M1: top-level AuditLogOptions + validator (redaction policy, payload caps, etc.). services.AddOptions() .Bind(config.GetSection(ConfigSectionName)) .ValidateOnStart(); services.AddSingleton, AuditLogOptionsValidator>(); + // M2 Bundle E: site writer + telemetry options bindings. + // BindConfiguration is not used because the configuration root supplied + // by the caller may not be the application root — we go through the + // section explicitly so a partial IConfiguration (e.g. a test stub + // anchored on the AuditLog section's parent) still works. + services.AddOptions() + .Bind(config.GetSection(SiteWriterSectionName)); + services.AddOptions() + .Bind(config.GetSection(SiteTelemetrySectionName)); + + // SqliteAuditWriter is a singleton with a single owned SqliteConnection + // and a background writer Task; multiple instances would race on the + // same file. Registered concretely so the ISiteAuditQueue + IAuditWriter + // forwards below resolve to the same instance — the actor must observe + // the writes made via the hot-path interface. + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + + // RingBufferFallback: drop-oldest in-memory ring used by + // FallbackAuditWriter when the primary SQLite writer throws. Default + // capacity is fine for M2 (1024). + services.AddSingleton(); + + // IAuditWriteFailureCounter: NoOp default. Bundle G overrides this + // binding with the real Site Health Monitoring counter. Registered + // before FallbackAuditWriter so the factory can resolve it. + services.AddSingleton(); + + // The script-thread surface is FallbackAuditWriter (primary + ring + + // counter), not the raw SqliteAuditWriter — primary failures must NEVER + // abort the user-facing action. + services.AddSingleton(sp => new FallbackAuditWriter( + primary: sp.GetRequiredService(), + ring: sp.GetRequiredService(), + failureCounter: sp.GetRequiredService(), + logger: sp.GetRequiredService>())); + + // ISiteStreamAuditClient: NoOp default. M6's reconciliation work brings + // the real gRPC-backed implementation (no site→central gRPC channel + // exists today — sites talk to central via Akka ClusterClient only). + // Bundle H's integration test substitutes a stub directly into the + // SiteAuditTelemetryActor's Props.Create call. + services.AddSingleton(); + + return services; + } + + /// + /// Audit Log (#23) M2 Bundle G — swap the default + /// registration for the real + /// bridge so the + /// FallbackAuditWriter primary-failure counter surfaces in the site health + /// report payload as SiteHealthReport.SiteAuditWriteFailures. + /// + /// + /// + /// Must be called AFTER both (registers the + /// NoOp default this method replaces) and + /// ScadaLink.HealthMonitoring.ServiceCollectionExtensions.AddHealthMonitoring + /// or AddSiteHealthMonitoring (registers the + /// the bridge depends on). Resolving + /// without the latter throws + /// at GetRequiredService + /// time — by design, since a silent NoOp would mask a misconfiguration. + /// + /// + /// Idempotent — calling twice replaces the descriptor each time without + /// piling up registrations. + /// + /// + public static IServiceCollection AddAuditLogHealthMetricsBridge(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.Replace( + ServiceDescriptor.Singleton()); return services; } } diff --git a/src/ScadaLink.AuditLog/Site/FallbackAuditWriter.cs b/src/ScadaLink.AuditLog/Site/FallbackAuditWriter.cs new file mode 100644 index 0000000..9b911c5 --- /dev/null +++ b/src/ScadaLink.AuditLog/Site/FallbackAuditWriter.cs @@ -0,0 +1,125 @@ +using Microsoft.Extensions.Logging; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Services; + +namespace ScadaLink.AuditLog.Site; + +/// +/// Composes the primary with a drop-oldest +/// . Audit writes are best-effort by contract +/// (see ) — a primary failure must NEVER bubble out +/// to the calling script. Failed events are stashed in the ring; on the next +/// successful primary write the ring is drained back through the primary in +/// FIFO order. +/// +/// +/// +/// Each primary failure increments so +/// Site Health Monitoring can surface a sustained outage as +/// SiteAuditWriteFailures (Bundle G). +/// +/// +/// Errors raised by the ring drain on recovery are logged and silently dropped +/// so we don't loop the failure mode — the trigger event itself succeeded, and +/// retrying the drain on the NEXT successful write is the recovery path. +/// +/// +public sealed class FallbackAuditWriter : IAuditWriter +{ + private readonly IAuditWriter _primary; + private readonly RingBufferFallback _ring; + private readonly IAuditWriteFailureCounter _failureCounter; + private readonly ILogger _logger; + private readonly SemaphoreSlim _drainGate = new(1, 1); + + public FallbackAuditWriter( + IAuditWriter primary, + RingBufferFallback ring, + IAuditWriteFailureCounter failureCounter, + ILogger logger) + { + _primary = primary ?? throw new ArgumentNullException(nameof(primary)); + _ring = ring ?? throw new ArgumentNullException(nameof(ring)); + _failureCounter = failureCounter ?? throw new ArgumentNullException(nameof(failureCounter)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task WriteAsync(AuditEvent evt, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(evt); + + try + { + await _primary.WriteAsync(evt, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + // Primary down: record the failure, stash in the ring, return + // success to the caller. Audit-write failures NEVER abort the + // user-facing action (alog.md §7). DO NOT attempt the ring drain + // here — primary is throwing, draining would just scramble FIFO + // order across re-enqueues. + _failureCounter.Increment(); + _logger.LogWarning(ex, + "Primary audit writer threw; routing EventId {EventId} to drop-oldest ring.", + evt.EventId); + _ring.TryEnqueue(evt); + return; + } + + // Primary succeeded — opportunistically drain anything that piled up + // in the ring during the outage. Best-effort: a failure during the + // drain re-enqueues the popped event and is logged; the next + // successful write will retry. Drain order in the audit log is + // therefore: , . + if (_ring.Count > 0) + { + await TryDrainRingAsync(ct).ConfigureAwait(false); + } + } + + private async Task TryDrainRingAsync(CancellationToken ct) + { + // Serialise drains so two concurrent recoveries don't double-replay. + if (!await _drainGate.WaitAsync(0, ct).ConfigureAwait(false)) + { + return; + } + + try + { + // Pull only what is currently buffered; do NOT wait for new events. + // We iterate with a snapshot of Count so we never starve under + // concurrent enqueues. + var pending = _ring.Count; + for (var i = 0; i < pending; i++) + { + if (!_ring.TryDequeue(out var queued)) + { + break; + } + + try + { + await _primary.WriteAsync(queued, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + // Primary fell over again. Put the event back at the head + // of the queue is impossible with Channel; route to the + // tail (drop-oldest preserves the most-recent picture). + _failureCounter.Increment(); + _logger.LogWarning(ex, + "Ring drain re-throw on EventId {EventId}; re-enqueuing.", + queued.EventId); + _ring.TryEnqueue(queued); + break; + } + } + } + finally + { + _drainGate.Release(); + } + } +} diff --git a/src/ScadaLink.AuditLog/Site/HealthMetricsAuditWriteFailureCounter.cs b/src/ScadaLink.AuditLog/Site/HealthMetricsAuditWriteFailureCounter.cs new file mode 100644 index 0000000..7284727 --- /dev/null +++ b/src/ScadaLink.AuditLog/Site/HealthMetricsAuditWriteFailureCounter.cs @@ -0,0 +1,33 @@ +using ScadaLink.HealthMonitoring; + +namespace ScadaLink.AuditLog.Site; + +/// +/// Audit Log (#23) M2 Bundle G — bridges +/// (incremented by every time the primary +/// SQLite writer throws) into so the count +/// surfaces in the site health report payload as +/// SiteHealthReport.SiteAuditWriteFailures. +/// +/// +/// +/// Registered by ; +/// callers must register AddHealthMonitoring() first so +/// resolves. The default +/// registration keeps for nodes +/// where Site Health Monitoring is not wired (the silent-sink contract — audit +/// write failures must NEVER abort the user-facing action, alog.md §7). +/// +/// +public sealed class HealthMetricsAuditWriteFailureCounter : IAuditWriteFailureCounter +{ + private readonly ISiteHealthCollector _collector; + + public HealthMetricsAuditWriteFailureCounter(ISiteHealthCollector collector) + { + _collector = collector ?? throw new ArgumentNullException(nameof(collector)); + } + + /// + public void Increment() => _collector.IncrementSiteAuditWriteFailures(); +} diff --git a/src/ScadaLink.AuditLog/Site/IAuditWriteFailureCounter.cs b/src/ScadaLink.AuditLog/Site/IAuditWriteFailureCounter.cs new file mode 100644 index 0000000..2c7305c --- /dev/null +++ b/src/ScadaLink.AuditLog/Site/IAuditWriteFailureCounter.cs @@ -0,0 +1,14 @@ +namespace ScadaLink.AuditLog.Site; + +/// +/// Lightweight counter sink invoked by every +/// time the primary throws on an audit write. +/// Bundle G (M2-T11) implements this as a thread-safe Interlocked counter +/// bridged into the Site Health Monitoring report payload as +/// SiteAuditWriteFailures. +/// +public interface IAuditWriteFailureCounter +{ + /// Increment the audit-write failure counter by one. + void Increment(); +} diff --git a/src/ScadaLink.AuditLog/Site/NoOpAuditWriteFailureCounter.cs b/src/ScadaLink.AuditLog/Site/NoOpAuditWriteFailureCounter.cs new file mode 100644 index 0000000..b3d7d91 --- /dev/null +++ b/src/ScadaLink.AuditLog/Site/NoOpAuditWriteFailureCounter.cs @@ -0,0 +1,25 @@ +namespace ScadaLink.AuditLog.Site; + +/// +/// Default registered by +/// on +/// every node. Bundle G replaces this binding with a real counter that bridges +/// into the Site Health Monitoring report payload as +/// SiteAuditWriteFailures — until then, +/// emits to a silent sink rather than NRE-ing +/// on a null collaborator. +/// +/// +/// Audit-write failures must NEVER abort the user-facing action (alog.md §7), +/// so the counter is best-effort by contract. A NoOp default is the correct +/// safe fallback while the health metric is being wired in. +/// +public sealed class NoOpAuditWriteFailureCounter : IAuditWriteFailureCounter +{ + /// + public void Increment() + { + // Intentionally empty. Bundle G overrides this binding with the real + // counter once Site Health Monitoring is wired. + } +} diff --git a/src/ScadaLink.AuditLog/Site/RingBufferFallback.cs b/src/ScadaLink.AuditLog/Site/RingBufferFallback.cs new file mode 100644 index 0000000..cf38dcd --- /dev/null +++ b/src/ScadaLink.AuditLog/Site/RingBufferFallback.cs @@ -0,0 +1,115 @@ +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using ScadaLink.Commons.Entities.Audit; + +namespace ScadaLink.AuditLog.Site; + +/// +/// Drop-oldest in-memory ring buffer used by +/// when the primary SQLite writer is throwing. Capacity is fixed at construction +/// (default 1024). When full, the oldest event is silently dropped to make room +/// for the newest — preserving the most recent picture of activity in the face +/// of an extended SQLite outage — and is +/// raised so a health counter can record the loss. +/// +/// +/// +/// Backed by a with +/// . The channel doesn't natively +/// notify on drop, so this class compares Reader.Count before and after +/// each enqueue: any time we hit capacity and a subsequent enqueue keeps the +/// count at capacity, exactly one event has been dropped. +/// +/// +/// Per the M2 plan: the ring is the absolute-last-resort buffer for the +/// hot-path; it is NOT a substitute for the bounded +/// write queue. +/// +/// +public sealed class RingBufferFallback +{ + private readonly Channel _channel; + private readonly int _capacity; + + /// + /// Raised once each time a drop-oldest overflow occurs. Hooked by + /// 's health counter wiring. + /// + public event Action? RingBufferOverflowed; + + public RingBufferFallback(int capacity = 1024) + { + if (capacity <= 0) + { + throw new ArgumentOutOfRangeException(nameof(capacity), "capacity must be > 0."); + } + + _capacity = capacity; + _channel = Channel.CreateBounded(new BoundedChannelOptions(capacity) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false, + }); + } + + /// Current event count in the ring (for diagnostics/tests). + public int Count => _channel.Reader.Count; + + /// + /// Try to enqueue an event. Returns on success (even + /// when an overflow caused an older event to be dropped); returns + /// only when the ring has been + /// -d. + /// + public bool TryEnqueue(AuditEvent evt) + { + ArgumentNullException.ThrowIfNull(evt); + + // DropOldest TryWrite always succeeds unless the channel is completed. + // Detect overflow by comparing the count before vs. after: if we were + // already at capacity and remain at capacity, exactly one event was + // dropped to make room for evt. + var beforeCount = _channel.Reader.Count; + if (!_channel.Writer.TryWrite(evt)) + { + return false; + } + + if (beforeCount >= _capacity) + { + // The new event displaced an existing one. + RingBufferOverflowed?.Invoke(); + } + + return true; + } + + /// + /// Drain the ring in FIFO order. Yields available events immediately and + /// then completes when the channel is empty AND has + /// been called. Callers that only want to drain what's currently buffered + /// must call first. + /// + public async IAsyncEnumerable DrainAsync( + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var evt in _channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + yield return evt; + } + } + + /// + /// Non-blocking single-item dequeue used by the + /// recovery path. Returns + /// when the ring is empty. + /// + public bool TryDequeue(out AuditEvent evt) => _channel.Reader.TryRead(out evt!); + + /// + /// Mark the ring as no-more-writes. will yield the + /// remaining events and then complete. + /// + public void Complete() => _channel.Writer.TryComplete(); +} diff --git a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs new file mode 100644 index 0000000..789b572 --- /dev/null +++ b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs @@ -0,0 +1,479 @@ +using System.Threading.Channels; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ScadaLink.AuditLog.Site.Telemetry; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.AuditLog.Site; + +/// +/// Site-side SQLite hot-path writer for Audit Log (#23) events. Mirrors the +/// design — a single +/// owned serialised behind a write lock, fed by a +/// bounded drained on a dedicated background writer +/// task — so script-thread callers never block on disk I/O. +/// +/// +/// +/// The schema is bootstrapped in the constructor (Bundle B-T1). The +/// Channel-based hot-path + Bundle D +/// / support +/// surface are wired in Bundle B-T2. +/// +/// +/// Site rows always carry on first +/// insert; the central row-shape's IngestedAtUtc column does NOT live in +/// the site SQLite schema — central stamps it on ingest. +/// +/// +public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable, IDisposable +{ + // Microsoft.Data.Sqlite reports a generic SQLITE_CONSTRAINT (error code 19) + // on a PRIMARY KEY violation; the extended subcode 1555 (SQLITE_CONSTRAINT_PRIMARYKEY) + // is exposed via SqliteException.SqliteExtendedErrorCode but isn't reliably + // surfaced across all SQLite builds. We treat any constraint error on insert + // as a duplicate-eventid race and swallow it (first-write-wins) — the index + // on EventId is the only constraint on this table, so this scope is precise. + private const int SqliteErrorConstraint = 19; + + private readonly SqliteConnection _connection; + private readonly SqliteAuditWriterOptions _options; + private readonly ILogger _logger; + private readonly object _writeLock = new(); + private readonly Channel _writeQueue; + private readonly Task _writerLoop; + private bool _disposed; + + public SqliteAuditWriter( + IOptions options, + ILogger logger, + string? connectionStringOverride = null) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(logger); + + _options = options.Value; + _logger = logger; + + var connectionString = connectionStringOverride + ?? $"Data Source={_options.DatabasePath};Cache=Shared"; + _connection = new SqliteConnection(connectionString); + _connection.Open(); + + InitializeSchema(); + + _writeQueue = Channel.CreateBounded( + new BoundedChannelOptions(_options.ChannelCapacity) + { + // The hot-path enqueue must back-pressure if the background + // writer falls behind; a higher-level fallback (Bundle B-T4) + // handles truly catastrophic primary failure with a drop-oldest + // ring buffer. + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true, + SingleWriter = false, + }); + _writerLoop = Task.Run(ProcessWriteQueueAsync); + } + + private void InitializeSchema() + { + // auto_vacuum must be set before any table is created for it to take + // effect on a fresh database. INCREMENTAL lets a future + // `PRAGMA incremental_vacuum` shrink the file after the 7-day retention + // purge — see alog.md §10. + using (var pragmaCmd = _connection.CreateCommand()) + { + pragmaCmd.CommandText = "PRAGMA auto_vacuum = INCREMENTAL"; + pragmaCmd.ExecuteNonQuery(); + } + + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS AuditLog ( + EventId TEXT NOT NULL, + OccurredAtUtc TEXT NOT NULL, + Channel TEXT NOT NULL, + Kind TEXT NOT NULL, + CorrelationId TEXT NULL, + SourceSiteId TEXT NULL, + SourceInstanceId TEXT NULL, + SourceScript TEXT NULL, + Actor TEXT NULL, + Target TEXT NULL, + Status TEXT NOT NULL, + HttpStatus INTEGER NULL, + DurationMs INTEGER NULL, + ErrorMessage TEXT NULL, + ErrorDetail TEXT NULL, + RequestSummary TEXT NULL, + ResponseSummary TEXT NULL, + PayloadTruncated INTEGER NOT NULL, + Extra TEXT NULL, + ForwardState TEXT NOT NULL, + PRIMARY KEY (EventId) + ); + CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred + ON AuditLog (ForwardState, OccurredAtUtc); + """; + cmd.ExecuteNonQuery(); + } + + /// + /// Enqueue an event for durable persistence. The returned + /// completes once the event has been INSERTed (or, in the duplicate-EventId + /// case, recognised as already present); it faults only if the writer loop + /// itself collapses. The enqueue side never blocks on disk I/O — it only + /// awaits the bounded channel's back-pressure when the writer is briefly + /// behind. + /// + public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(evt); + + // Site rows always carry a non-null ForwardState; central rows leave it + // null. Force Pending on enqueue so callers can pass a bare AuditEvent + // without thinking about site-vs-central provenance. + var siteEvt = evt.ForwardState is null + ? evt with { ForwardState = AuditForwardState.Pending } + : evt; + + var pending = new PendingAuditEvent(siteEvt); + + // CreateBounded(FullMode=Wait) means WriteAsync will await room rather + // than throw when full — exactly the hot-path back-pressure semantics + // we want. + if (!_writeQueue.Writer.TryWrite(pending)) + { + // The writer is either completed (logger disposed) or the channel + // is at capacity. Fall back to the async path which honours the + // FullMode=Wait policy. + return WriteSlowPathAsync(pending, ct); + } + + return pending.Completion.Task; + } + + private async Task WriteSlowPathAsync(PendingAuditEvent pending, CancellationToken ct) + { + try + { + await _writeQueue.Writer.WriteAsync(pending, ct).ConfigureAwait(false); + } + catch (ChannelClosedException) + { + pending.Completion.TrySetException( + new ObjectDisposedException(nameof(SqliteAuditWriter), + "Event could not be recorded: the audit writer has been disposed.")); + } + + await pending.Completion.Task.ConfigureAwait(false); + } + + private async Task ProcessWriteQueueAsync() + { + var batch = new List(_options.BatchSize); + + // ReadAllAsync completes when the channel is marked complete (Dispose). + await foreach (var first in _writeQueue.Reader.ReadAllAsync().ConfigureAwait(false)) + { + batch.Clear(); + batch.Add(first); + + // Pull additional ready events up to BatchSize. TryRead is non- + // blocking and lets us amortise the transaction overhead across a + // burst of concurrent enqueues. + while (batch.Count < _options.BatchSize && + _writeQueue.Reader.TryRead(out var next)) + { + batch.Add(next); + } + + FlushBatch(batch); + } + } + + private void FlushBatch(IReadOnlyList batch) + { + lock (_writeLock) + { + if (_disposed) + { + foreach (var pending in batch) + { + pending.Completion.TrySetException( + new ObjectDisposedException(nameof(SqliteAuditWriter), + "Event could not be recorded: the audit writer was disposed before the write completed.")); + } + return; + } + + using var transaction = _connection.BeginTransaction(); + try + { + using var cmd = _connection.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = """ + INSERT INTO AuditLog ( + EventId, OccurredAtUtc, Channel, Kind, CorrelationId, + SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, + Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, + RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState + ) VALUES ( + $EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId, + $SourceSiteId, $SourceInstanceId, $SourceScript, $Actor, $Target, + $Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail, + $RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState + ); + """; + + var pEventId = cmd.Parameters.Add("$EventId", SqliteType.Text); + var pOccurredAt = cmd.Parameters.Add("$OccurredAtUtc", SqliteType.Text); + var pChannel = cmd.Parameters.Add("$Channel", SqliteType.Text); + var pKind = cmd.Parameters.Add("$Kind", SqliteType.Text); + var pCorrelationId = cmd.Parameters.Add("$CorrelationId", SqliteType.Text); + var pSourceSiteId = cmd.Parameters.Add("$SourceSiteId", SqliteType.Text); + var pSourceInstanceId = cmd.Parameters.Add("$SourceInstanceId", SqliteType.Text); + var pSourceScript = cmd.Parameters.Add("$SourceScript", SqliteType.Text); + var pActor = cmd.Parameters.Add("$Actor", SqliteType.Text); + var pTarget = cmd.Parameters.Add("$Target", SqliteType.Text); + var pStatus = cmd.Parameters.Add("$Status", SqliteType.Text); + var pHttpStatus = cmd.Parameters.Add("$HttpStatus", SqliteType.Integer); + var pDurationMs = cmd.Parameters.Add("$DurationMs", SqliteType.Integer); + var pErrorMessage = cmd.Parameters.Add("$ErrorMessage", SqliteType.Text); + var pErrorDetail = cmd.Parameters.Add("$ErrorDetail", SqliteType.Text); + var pRequestSummary = cmd.Parameters.Add("$RequestSummary", SqliteType.Text); + var pResponseSummary = cmd.Parameters.Add("$ResponseSummary", SqliteType.Text); + var pPayloadTruncated = cmd.Parameters.Add("$PayloadTruncated", SqliteType.Integer); + var pExtra = cmd.Parameters.Add("$Extra", SqliteType.Text); + var pForwardState = cmd.Parameters.Add("$ForwardState", SqliteType.Text); + + foreach (var pending in batch) + { + var e = pending.Event; + pEventId.Value = e.EventId.ToString(); + pOccurredAt.Value = e.OccurredAtUtc.ToString("o"); + pChannel.Value = e.Channel.ToString(); + pKind.Value = e.Kind.ToString(); + pCorrelationId.Value = (object?)e.CorrelationId?.ToString() ?? DBNull.Value; + pSourceSiteId.Value = (object?)e.SourceSiteId ?? DBNull.Value; + pSourceInstanceId.Value = (object?)e.SourceInstanceId ?? DBNull.Value; + pSourceScript.Value = (object?)e.SourceScript ?? DBNull.Value; + pActor.Value = (object?)e.Actor ?? DBNull.Value; + pTarget.Value = (object?)e.Target ?? DBNull.Value; + pStatus.Value = e.Status.ToString(); + pHttpStatus.Value = (object?)e.HttpStatus ?? DBNull.Value; + pDurationMs.Value = (object?)e.DurationMs ?? DBNull.Value; + pErrorMessage.Value = (object?)e.ErrorMessage ?? DBNull.Value; + pErrorDetail.Value = (object?)e.ErrorDetail ?? DBNull.Value; + pRequestSummary.Value = (object?)e.RequestSummary ?? DBNull.Value; + pResponseSummary.Value = (object?)e.ResponseSummary ?? DBNull.Value; + pPayloadTruncated.Value = e.PayloadTruncated ? 1 : 0; + pExtra.Value = (object?)e.Extra ?? DBNull.Value; + pForwardState.Value = (e.ForwardState ?? AuditForwardState.Pending).ToString(); + + try + { + cmd.ExecuteNonQuery(); + pending.Completion.TrySetResult(); + } + catch (SqliteException ex) when (ex.SqliteErrorCode == SqliteErrorConstraint) + { + // Duplicate EventId — first-write-wins (alog.md §11). + // Treat as success: the lifecycle event is durably + // recorded under the first writer's payload. + _logger.LogDebug(ex, + "Duplicate EventId {EventId} swallowed by SqliteAuditWriter", + e.EventId); + pending.Completion.TrySetResult(); + } + } + + transaction.Commit(); + } + catch (Exception ex) + { + transaction.Rollback(); + _logger.LogError(ex, "SqliteAuditWriter batch insert failed; faulting {Count} pending events", batch.Count); + foreach (var pending in batch) + { + pending.Completion.TrySetException(ex); + } + } + } + } + + /// + /// Returns up to rows in , + /// oldest first, with + /// as the deterministic tiebreaker. Called by Bundle D's site telemetry + /// actor to build a batch for the gRPC push. + /// + public Task> ReadPendingAsync(int limit, CancellationToken ct = default) + { + if (limit <= 0) + { + throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0."); + } + + // SqliteConnection is not thread-safe so we go through the same write + // lock the batch INSERTer uses. The actor caller is single-threaded, + // so contention is bounded. + lock (_writeLock) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, + SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, + Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, + RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState + FROM AuditLog + WHERE ForwardState = $pending + ORDER BY OccurredAtUtc ASC, EventId ASC + LIMIT $limit; + """; + cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString()); + cmd.Parameters.AddWithValue("$limit", limit); + + var rows = new List(Math.Min(limit, 256)); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + rows.Add(MapRow(reader)); + } + + return Task.FromResult>(rows); + } + } + + /// + /// Flips the supplied EventIds from to + /// in a single UPDATE. Non-existent + /// or already-forwarded ids are no-ops. + /// + public Task MarkForwardedAsync(IReadOnlyList eventIds, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(eventIds); + if (eventIds.Count == 0) + { + return Task.CompletedTask; + } + + lock (_writeLock) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + using var cmd = _connection.CreateCommand(); + // Build a single IN (...) parameter list so we issue one UPDATE per + // batch regardless of size. Each id is bound as its own parameter, + // so no string concatenation of user data ever enters the SQL. + var sb = new System.Text.StringBuilder(); + sb.Append("UPDATE AuditLog SET ForwardState = $forwarded WHERE EventId IN ("); + for (int i = 0; i < eventIds.Count; i++) + { + if (i > 0) sb.Append(','); + var p = $"$id{i}"; + sb.Append(p); + cmd.Parameters.AddWithValue(p, eventIds[i].ToString()); + } + sb.Append(");"); + cmd.CommandText = sb.ToString(); + cmd.Parameters.AddWithValue("$forwarded", AuditForwardState.Forwarded.ToString()); + + cmd.ExecuteNonQuery(); + return Task.CompletedTask; + } + } + + private static AuditEvent MapRow(SqliteDataReader reader) + { + return new AuditEvent + { + EventId = Guid.Parse(reader.GetString(0)), + OccurredAtUtc = DateTime.Parse(reader.GetString(1), + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.RoundtripKind), + Channel = Enum.Parse(reader.GetString(2)), + Kind = Enum.Parse(reader.GetString(3)), + CorrelationId = reader.IsDBNull(4) ? null : Guid.Parse(reader.GetString(4)), + SourceSiteId = reader.IsDBNull(5) ? null : reader.GetString(5), + SourceInstanceId = reader.IsDBNull(6) ? null : reader.GetString(6), + SourceScript = reader.IsDBNull(7) ? null : reader.GetString(7), + Actor = reader.IsDBNull(8) ? null : reader.GetString(8), + Target = reader.IsDBNull(9) ? null : reader.GetString(9), + Status = Enum.Parse(reader.GetString(10)), + HttpStatus = reader.IsDBNull(11) ? null : reader.GetInt32(11), + DurationMs = reader.IsDBNull(12) ? null : reader.GetInt32(12), + ErrorMessage = reader.IsDBNull(13) ? null : reader.GetString(13), + ErrorDetail = reader.IsDBNull(14) ? null : reader.GetString(14), + RequestSummary = reader.IsDBNull(15) ? null : reader.GetString(15), + ResponseSummary = reader.IsDBNull(16) ? null : reader.GetString(16), + PayloadTruncated = reader.GetInt32(17) != 0, + Extra = reader.IsDBNull(18) ? null : reader.GetString(18), + ForwardState = Enum.Parse(reader.GetString(19)), + }; + } + + public void Dispose() + { + DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + + public async ValueTask DisposeAsync() + { + Task? writerLoop; + lock (_writeLock) + { + if (_disposed) return; + // Stop accepting new events. Setting _disposed first ensures any + // FlushBatch entered after we mark disposed will fault its pending + // events rather than touching the about-to-close connection. + _writeQueue.Writer.TryComplete(); + writerLoop = _writerLoop; + } + + // Wait outside the lock — the loop reacquires it for each batch. + try + { + if (writerLoop is not null) + { + await writerLoop.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } + } + catch (TimeoutException) + { + _logger.LogWarning("SqliteAuditWriter writer loop did not drain within 5s of dispose."); + } + catch (Exception ex) + { + // The loop's per-batch try/catch already routed individual failures + // to pending TCSes; a top-level fault here is unexpected. + _logger.LogError(ex, "SqliteAuditWriter writer loop faulted during dispose."); + } + + lock (_writeLock) + { + if (_disposed) return; + _disposed = true; + _connection.Dispose(); + } + } + + /// An audit event awaiting persistence by the background writer. + private sealed class PendingAuditEvent + { + public PendingAuditEvent(AuditEvent evt) + { + Event = evt; + Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + public AuditEvent Event { get; } + public TaskCompletionSource Completion { get; } + } +} diff --git a/src/ScadaLink.AuditLog/Site/SqliteAuditWriterOptions.cs b/src/ScadaLink.AuditLog/Site/SqliteAuditWriterOptions.cs new file mode 100644 index 0000000..8f1fdc1 --- /dev/null +++ b/src/ScadaLink.AuditLog/Site/SqliteAuditWriterOptions.cs @@ -0,0 +1,27 @@ +namespace ScadaLink.AuditLog.Site; + +/// +/// Options for the site-side SQLite hot-path audit writer. +/// Mirrors the ScadaLink.SiteEventLogging pattern: a single SQLite connection +/// fed by a background writer task draining a bounded +/// so script-thread enqueues +/// never block on disk I/O. +/// +public sealed class SqliteAuditWriterOptions +{ + /// SQLite database path (or in-memory URI for tests). + public string DatabasePath { get; set; } = "auditlog.db"; + + /// + /// Capacity of the bounded write queue. Set high enough that ordinary + /// script bursts never fill it; + /// applies when the writer falls behind. + /// + public int ChannelCapacity { get; set; } = 4096; + + /// Max number of pending events the writer drains in one transaction. + public int BatchSize { get; set; } = 256; + + /// Soft flush interval the writer enforces when fewer than BatchSize events are queued. + public int FlushIntervalMs { get; set; } = 50; +} diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/ISiteAuditQueue.cs b/src/ScadaLink.AuditLog/Site/Telemetry/ISiteAuditQueue.cs new file mode 100644 index 0000000..9da55b5 --- /dev/null +++ b/src/ScadaLink.AuditLog/Site/Telemetry/ISiteAuditQueue.cs @@ -0,0 +1,34 @@ +using ScadaLink.Commons.Entities.Audit; + +namespace ScadaLink.AuditLog.Site.Telemetry; + +/// +/// Site-local audit-log queue surface consumed by . +/// Extracted from so the telemetry actor can be +/// unit-tested against a stub without touching SQLite. +/// implements this interface; production wiring injects the same instance. +/// +/// +/// Only the two methods the drain loop needs are exposed — the hot-path +/// WriteAsync stays on +/// (script-thread surface), separated by concern from the +/// telemetry-actor surface so each side can be mocked independently. +/// +public interface ISiteAuditQueue +{ + /// + /// Returns up to rows currently in + /// , + /// oldest first. Idempotent — repeated calls before + /// will yield the same rows again. + /// + Task> ReadPendingAsync(int limit, CancellationToken ct = default); + + /// + /// Flips the supplied EventIds from + /// to + /// . + /// Non-existent or already-forwarded ids are silent no-ops. + /// + Task MarkForwardedAsync(IReadOnlyList eventIds, CancellationToken ct = default); +} diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/ISiteStreamAuditClient.cs b/src/ScadaLink.AuditLog/Site/Telemetry/ISiteStreamAuditClient.cs new file mode 100644 index 0000000..c25b05a --- /dev/null +++ b/src/ScadaLink.AuditLog/Site/Telemetry/ISiteStreamAuditClient.cs @@ -0,0 +1,23 @@ +using ScadaLink.Communication.Grpc; + +namespace ScadaLink.AuditLog.Site.Telemetry; + +/// +/// Mockable abstraction over the central site-stream gRPC client surface that +/// uses to push +/// payloads. The production implementation (added in Bundle E host wiring) +/// wraps the auto-generated SiteStreamService.SiteStreamServiceClient; +/// unit tests substitute via NSubstitute against this interface so the actor +/// never needs a live gRPC channel. +/// +public interface ISiteStreamAuditClient +{ + /// + /// Pushes to the central IngestAuditEvents + /// RPC. The returned carries the + /// accepted_event_ids the actor will flip to + /// + /// in the site SQLite queue. + /// + Task IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct); +} diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/NoOpSiteStreamAuditClient.cs b/src/ScadaLink.AuditLog/Site/Telemetry/NoOpSiteStreamAuditClient.cs new file mode 100644 index 0000000..b1a0190 --- /dev/null +++ b/src/ScadaLink.AuditLog/Site/Telemetry/NoOpSiteStreamAuditClient.cs @@ -0,0 +1,41 @@ +using ScadaLink.Communication.Grpc; + +namespace ScadaLink.AuditLog.Site.Telemetry; + +/// +/// Default registered by +/// . +/// Ships with M2 site-sync-pipeline wiring; the real gRPC-backed +/// implementation is deferred to M6 reconciliation, where a site→central gRPC +/// channel will be introduced (no such channel exists today — sites talk to +/// central exclusively via Akka ClusterClient, while the gRPC SiteStreamService +/// is hosted on the SITE side for central→site streaming). +/// +/// +/// +/// Returns an empty so the +/// doesn't flip any rows to +/// Forwarded when this NoOp is in effect — Bundle H's integration test +/// substitutes a stub client that routes directly to the central +/// AuditLogIngestActor in-process. Production wiring (M6) will replace +/// this binding with a real client. +/// +/// +/// Audit-write paths are best-effort by contract: a NoOp client keeps the +/// host running cleanly and is consistent with "audit-write failures never +/// abort the user-facing action". +/// +/// +public sealed class NoOpSiteStreamAuditClient : ISiteStreamAuditClient +{ + private static readonly IngestAck EmptyAck = new(); + + /// + public Task IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(batch); + // Empty ack — no EventIds will be flipped to Forwarded, so rows stay + // Pending until M6's real client (or a Bundle H test stub) takes over. + return Task.FromResult(EmptyAck); + } +} diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs b/src/ScadaLink.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs new file mode 100644 index 0000000..a820cf5 --- /dev/null +++ b/src/ScadaLink.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs @@ -0,0 +1,179 @@ +using Akka.Actor; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ScadaLink.AuditLog.Telemetry; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Communication.Grpc; + +namespace ScadaLink.AuditLog.Site.Telemetry; + +/// +/// Site-side actor that drains the local SQLite audit queue and pushes Pending +/// rows to central via the IngestAuditEvents gRPC RPC. On a successful +/// ack the matching EventIds flip to +/// ; on +/// a gRPC failure the rows stay Pending and the next drain retries. +/// +/// +/// +/// The drain self-tick is a private Drain message scheduled via the +/// actor system scheduler. The cadence is options-driven: BusyIntervalSeconds +/// when the previous drain found rows (or faulted — we want quick recovery), +/// IdleIntervalSeconds when the queue was empty. +/// +/// +/// Both collaborators are injected as interfaces ( +/// and ) so unit tests substitute with +/// NSubstitute and never touch real SQLite or gRPC. +/// +/// +/// Per Bundle D's brief, audit-write paths must be fail-safe — a thrown +/// exception inside the actor MUST NOT crash it. The Drain handler wraps the +/// pipeline in a top-level try/catch that logs and re-schedules, and the +/// actor's defaults to +/// 's Restart for +/// child actors — but this actor has no children, so the catch is what matters. +/// +/// +public class SiteAuditTelemetryActor : ReceiveActor +{ + private readonly ISiteAuditQueue _queue; + private readonly ISiteStreamAuditClient _client; + private readonly SiteAuditTelemetryOptions _options; + private readonly ILogger _logger; + private ICancelable? _pendingTick; + + public SiteAuditTelemetryActor( + ISiteAuditQueue queue, + ISiteStreamAuditClient client, + IOptions options, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(queue); + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(logger); + + _queue = queue; + _client = client; + _options = options.Value; + _logger = logger; + + ReceiveAsync(_ => OnDrainAsync()); + } + + protected override void PreStart() + { + base.PreStart(); + // Initial tick fires on the busy interval so the actor starts polling + // soon after host startup. A subsequent empty drain will move to the + // idle interval naturally. + ScheduleNext(TimeSpan.FromSeconds(_options.BusyIntervalSeconds)); + } + + protected override void PostStop() + { + _pendingTick?.Cancel(); + base.PostStop(); + } + + private async Task OnDrainAsync() + { + var nextDelay = TimeSpan.FromSeconds(_options.BusyIntervalSeconds); + try + { + var pending = await _queue.ReadPendingAsync(_options.BatchSize, CancellationToken.None) + .ConfigureAwait(false); + if (pending.Count == 0) + { + // No rows — settle into the idle cadence until the next write + // bumps us back into the busy cadence. + nextDelay = TimeSpan.FromSeconds(_options.IdleIntervalSeconds); + return; + } + + var batch = BuildBatch(pending); + + IngestAck ack; + try + { + ack = await _client.IngestAuditEventsAsync(batch, CancellationToken.None) + .ConfigureAwait(false); + } + catch (Exception ex) + { + // gRPC fault — leave the rows in Pending so the next drain + // retries. Bundle D's brief: "On gRPC exception (any), log + // Warning, schedule next Drain in BusyIntervalSeconds." + _logger.LogWarning(ex, + "IngestAuditEvents push failed for {Count} pending events; will retry next drain.", + pending.Count); + return; + } + + var acceptedIds = ParseAcceptedIds(ack); + if (acceptedIds.Count > 0) + { + await _queue.MarkForwardedAsync(acceptedIds, CancellationToken.None) + .ConfigureAwait(false); + } + } + catch (Exception ex) + { + // Catch-all so a SQLite hiccup or mapper bug never crashes the + // actor. The next tick is still scheduled in the finally block. + _logger.LogError(ex, "Unexpected error during audit-log telemetry drain."); + } + finally + { + ScheduleNext(nextDelay); + } + } + + private static AuditEventBatch BuildBatch(IReadOnlyList events) + { + var batch = new AuditEventBatch(); + foreach (var e in events) + { + batch.Events.Add(AuditEventMapper.ToDto(e)); + } + return batch; + } + + private static IReadOnlyList ParseAcceptedIds(IngestAck ack) + { + if (ack.AcceptedEventIds.Count == 0) + { + return Array.Empty(); + } + + var list = new List(ack.AcceptedEventIds.Count); + foreach (var raw in ack.AcceptedEventIds) + { + if (Guid.TryParse(raw, out var id)) + { + list.Add(id); + } + // Malformed ids are ignored — central should never emit them, but + // we refuse to crash the actor over a bad string. + } + return list; + } + + private void ScheduleNext(TimeSpan delay) + { + _pendingTick?.Cancel(); + _pendingTick = Context.System.Scheduler.ScheduleTellOnceCancelable( + delay, + Self, + Drain.Instance, + Self); + } + + /// Self-tick message that triggers a drain cycle. + private sealed class Drain + { + public static readonly Drain Instance = new(); + private Drain() { } + } +} diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/SiteAuditTelemetryOptions.cs b/src/ScadaLink.AuditLog/Site/Telemetry/SiteAuditTelemetryOptions.cs new file mode 100644 index 0000000..9aab759 --- /dev/null +++ b/src/ScadaLink.AuditLog/Site/Telemetry/SiteAuditTelemetryOptions.cs @@ -0,0 +1,28 @@ +namespace ScadaLink.AuditLog.Site.Telemetry; + +/// +/// Tuning knobs for the site-side drain +/// loop. Defaults mirror Bundle D's plan: drain every 5 s while rows are +/// flowing (busy), every 30 s when the queue is empty (idle). +/// +public sealed class SiteAuditTelemetryOptions +{ + /// + /// Maximum number of + /// rows read from the site SQLite queue and pushed in a single gRPC batch. + /// + public int BatchSize { get; set; } = 256; + + /// + /// Delay between drains when the previous drain found at least one Pending + /// row OR the previous push faulted. Re-drain quickly to keep telemetry + /// flowing and to retry transient gRPC errors. + /// + public int BusyIntervalSeconds { get; set; } = 5; + + /// + /// Delay between drains when the previous drain found no Pending rows. + /// Longer interval avoids hammering an idle SQLite + gRPC channel. + /// + public int IdleIntervalSeconds { get; set; } = 30; +} diff --git a/src/ScadaLink.AuditLog/Telemetry/AuditEventMapper.cs b/src/ScadaLink.AuditLog/Telemetry/AuditEventMapper.cs new file mode 100644 index 0000000..d821db0 --- /dev/null +++ b/src/ScadaLink.AuditLog/Telemetry/AuditEventMapper.cs @@ -0,0 +1,112 @@ +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.Communication.Grpc; +using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp; + +namespace ScadaLink.AuditLog.Telemetry; + +/// +/// Bridges Audit Log (#23) rows between the in-process record +/// and the wire-format exchanged over the +/// IngestAuditEvents RPC. +/// +/// +/// Lossy by design: the proto contract intentionally omits two fields. +/// +/// — site-local SQLite state, never travels. +/// — central-set at ingest time, not at the site. +/// +/// +/// String nullability convention: proto3 scalar strings cannot be absent, so nullable +/// .NET strings round-trip as empty strings on the wire. Nullable integers use the +/// Int32Value wrapper so they preserve true null semantics. +/// +/// +public static class AuditEventMapper +{ + /// + /// Projects an into its wire-format DTO. Null reference + /// fields collapse to empty strings; null integer fields leave the wrapper unset. + /// + public static AuditEventDto ToDto(AuditEvent evt) + { + ArgumentNullException.ThrowIfNull(evt); + + var dto = new AuditEventDto + { + EventId = evt.EventId.ToString(), + OccurredAtUtc = Timestamp.FromDateTime(EnsureUtc(evt.OccurredAtUtc)), + Channel = evt.Channel.ToString(), + Kind = evt.Kind.ToString(), + CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty, + SourceSiteId = evt.SourceSiteId ?? string.Empty, + SourceInstanceId = evt.SourceInstanceId ?? string.Empty, + SourceScript = evt.SourceScript ?? string.Empty, + Actor = evt.Actor ?? string.Empty, + Target = evt.Target ?? string.Empty, + Status = evt.Status.ToString(), + ErrorMessage = evt.ErrorMessage ?? string.Empty, + ErrorDetail = evt.ErrorDetail ?? string.Empty, + RequestSummary = evt.RequestSummary ?? string.Empty, + ResponseSummary = evt.ResponseSummary ?? string.Empty, + PayloadTruncated = evt.PayloadTruncated, + Extra = evt.Extra ?? string.Empty + }; + + if (evt.HttpStatus.HasValue) + { + dto.HttpStatus = evt.HttpStatus.Value; + } + + if (evt.DurationMs.HasValue) + { + dto.DurationMs = evt.DurationMs.Value; + } + + return dto; + } + + /// + /// Reconstructs an from its wire-format DTO. Empty strings + /// rehydrate as null reference values; absent integer wrappers stay null. + /// and + /// are intentionally left null — the central ingest actor sets the latter. + /// + public static AuditEvent FromDto(AuditEventDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + + return new AuditEvent + { + EventId = Guid.Parse(dto.EventId), + OccurredAtUtc = DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc), + IngestedAtUtc = null, + Channel = Enum.Parse(dto.Channel), + Kind = Enum.Parse(dto.Kind), + CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null, + SourceSiteId = NullIfEmpty(dto.SourceSiteId), + SourceInstanceId = NullIfEmpty(dto.SourceInstanceId), + SourceScript = NullIfEmpty(dto.SourceScript), + Actor = NullIfEmpty(dto.Actor), + Target = NullIfEmpty(dto.Target), + Status = Enum.Parse(dto.Status), + HttpStatus = dto.HttpStatus, + DurationMs = dto.DurationMs, + ErrorMessage = NullIfEmpty(dto.ErrorMessage), + ErrorDetail = NullIfEmpty(dto.ErrorDetail), + RequestSummary = NullIfEmpty(dto.RequestSummary), + ResponseSummary = NullIfEmpty(dto.ResponseSummary), + PayloadTruncated = dto.PayloadTruncated, + Extra = NullIfEmpty(dto.Extra), + ForwardState = null + }; + } + + private static string? NullIfEmpty(string? value) => + string.IsNullOrEmpty(value) ? null : value; + + private static DateTime EnsureUtc(DateTime value) => + value.Kind == DateTimeKind.Utc + ? value + : DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc); +} diff --git a/src/ScadaLink.Commons/Messages/Audit/IngestAuditEventsCommand.cs b/src/ScadaLink.Commons/Messages/Audit/IngestAuditEventsCommand.cs new file mode 100644 index 0000000..372adaf --- /dev/null +++ b/src/ScadaLink.Commons/Messages/Audit/IngestAuditEventsCommand.cs @@ -0,0 +1,20 @@ +using ScadaLink.Commons.Entities.Audit; + +namespace ScadaLink.Commons.Messages.Audit; + +/// +/// Akka message sent to the central AuditLogIngestActor (Audit Log #23, +/// M2 site-sync pipeline) carrying a batch of rows +/// decoded by the SiteStreamGrpcServer from a site's +/// IngestAuditEvents gRPC RPC. The actor stamps +/// and writes the rows idempotently to +/// the central AuditLog table. +/// +/// +/// Lives in ScadaLink.Commons rather than ScadaLink.AuditLog +/// because the gRPC server in ScadaLink.Communication needs to construct +/// it, and ScadaLink.AuditLog already references +/// ScadaLink.Communication (the proto DTOs live there). Putting the +/// message in Commons avoids a project-reference cycle. +/// +public sealed record IngestAuditEventsCommand(IReadOnlyList Events); diff --git a/src/ScadaLink.Commons/Messages/Audit/IngestAuditEventsReply.cs b/src/ScadaLink.Commons/Messages/Audit/IngestAuditEventsReply.cs new file mode 100644 index 0000000..8d6d892 --- /dev/null +++ b/src/ScadaLink.Commons/Messages/Audit/IngestAuditEventsReply.cs @@ -0,0 +1,11 @@ +namespace ScadaLink.Commons.Messages.Audit; + +/// +/// Reply from the central AuditLogIngestActor for an +/// . lists +/// every row the actor considers durably persisted at central — including ids +/// that were already present before the call (first-write-wins idempotency). +/// The gRPC handler echoes these ids back over the wire as the IngestAck +/// the site uses to flip rows to Forwarded. +/// +public sealed record IngestAuditEventsReply(IReadOnlyList AcceptedEventIds); diff --git a/src/ScadaLink.Commons/Messages/Health/SiteHealthReport.cs b/src/ScadaLink.Commons/Messages/Health/SiteHealthReport.cs index d61b37e..516d4f3 100644 --- a/src/ScadaLink.Commons/Messages/Health/SiteHealthReport.cs +++ b/src/ScadaLink.Commons/Messages/Health/SiteHealthReport.cs @@ -20,7 +20,12 @@ public record SiteHealthReport( IReadOnlyDictionary? DataConnectionEndpoints = null, IReadOnlyDictionary? DataConnectionTagQuality = null, int ParkedMessageCount = 0, - IReadOnlyList? ClusterNodes = null); + IReadOnlyList? ClusterNodes = null, + // Audit Log (#23) M2 Bundle G: per-interval count of FallbackAuditWriter + // primary failures (SQLite throws routed to the drop-oldest ring). Surfaces + // a sustained audit-write outage on /monitoring/health. Defaults to 0 so + // existing producers / tests that don't construct the field stay valid. + int SiteAuditWriteFailures = 0); /// /// Broadcast wrapper used between central nodes to keep per-node diff --git a/src/ScadaLink.Communication/Grpc/SiteStreamGrpcServer.cs b/src/ScadaLink.Communication/Grpc/SiteStreamGrpcServer.cs index 02d0daa..71560c3 100644 --- a/src/ScadaLink.Communication/Grpc/SiteStreamGrpcServer.cs +++ b/src/ScadaLink.Communication/Grpc/SiteStreamGrpcServer.cs @@ -4,6 +4,9 @@ using Akka.Actor; using Grpc.Core; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Messages.Audit; +using ScadaLink.Commons.Types.Enums; using GrpcStatus = Grpc.Core.Status; namespace ScadaLink.Communication.Grpc; @@ -23,6 +26,15 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase private readonly TimeSpan _maxStreamLifetime; private volatile bool _ready; private long _actorCounter; + // Audit Log (#23 M2): central-side ingest actor proxy. Set by the host + // after the cluster singleton starts (see Bundle E wiring). When null the + // IngestAuditEvents RPC replies with an empty IngestAck so sites can + // safely retry — wiring-incomplete is treated as transient, never fatal. + private IActorRef? _auditIngestActor; + // Per Bundle D's brief — Ask timeout is 30 s. The ingest actor's repo + // calls are sub-100 ms in steady state; a generous timeout absorbs a slow + // MSSQL connection without surfacing as a gRPC failure on a healthy site. + private static readonly TimeSpan AuditIngestAskTimeout = TimeSpan.FromSeconds(30); /// /// Test-only constructor — kept internal so the DI container sees a @@ -76,6 +88,19 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase _ready = true; } + /// + /// Hands the central-side AuditLogIngestActor proxy to the gRPC + /// server so the RPC can route incoming + /// site batches. Audit Log (#23) M2 wiring point — mirrors the way + /// CommunicationService.SetNotificationOutbox takes the Notification + /// Outbox singleton proxy. Bundle E supplies the actor after the cluster + /// singleton starts. + /// + public void SetAuditIngestActor(IActorRef proxy) + { + _auditIngestActor = proxy; + } + /// /// Number of currently active streaming subscriptions. Exposed for diagnostics. /// @@ -168,6 +193,114 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase } } + /// + /// Audit Log (#23) M2 site→central push RPC. Decodes a site batch into + /// rows, Asks the central AuditLogIngestActor + /// proxy to persist them, and echoes the accepted EventIds back so the site + /// can flip its local rows to Forwarded. + /// + /// + /// + /// The DTO→entity conversion is inlined here (rather than calling the + /// AuditLog mapper) to avoid a project-reference cycle: + /// ScadaLink.AuditLog already references + /// ScadaLink.Communication, so the gRPC server cannot reach back + /// into AuditLog for its mapper. The shape mirrors + /// AuditEventMapper.FromDto in ScadaLink.AuditLog.Telemetry; + /// the two must evolve together. + /// + /// + /// When is not yet wired (host startup + /// race window), the RPC returns an empty rather + /// than failing — the site treats the missing ack as a transient outcome + /// and retries on the next drain, which is the desired idempotent + /// behaviour. + /// + /// + public override async Task IngestAuditEvents( + AuditEventBatch request, + ServerCallContext context) + { + // Empty batch is a no-op; reply immediately so the client moves on. + if (request.Events.Count == 0) + { + return new IngestAck(); + } + + var actor = _auditIngestActor; + if (actor is null) + { + // Wiring incomplete (host startup race). Sites treat an empty + // ack as "nothing was acked, leave rows Pending, retry next + // drain" — exactly the right behaviour during host bring-up. + _logger.LogWarning( + "IngestAuditEvents received {Count} events before SetAuditIngestActor was called; returning empty ack.", + request.Events.Count); + return new IngestAck(); + } + + // Inlined FromDto. Keep in sync with AuditEventMapper.FromDto in + // ScadaLink.AuditLog.Telemetry — there is no shared mapper because + // doing so would create a project-reference cycle (AuditLog → Communication). + var entities = new List(request.Events.Count); + foreach (var dto in request.Events) + { + entities.Add(new AuditEvent + { + EventId = Guid.Parse(dto.EventId), + OccurredAtUtc = DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc), + IngestedAtUtc = null, + Channel = Enum.Parse(dto.Channel), + Kind = Enum.Parse(dto.Kind), + CorrelationId = string.IsNullOrEmpty(dto.CorrelationId) ? null : Guid.Parse(dto.CorrelationId), + SourceSiteId = NullIfEmpty(dto.SourceSiteId), + SourceInstanceId = NullIfEmpty(dto.SourceInstanceId), + SourceScript = NullIfEmpty(dto.SourceScript), + Actor = NullIfEmpty(dto.Actor), + Target = NullIfEmpty(dto.Target), + Status = Enum.Parse(dto.Status), + HttpStatus = dto.HttpStatus, + DurationMs = dto.DurationMs, + ErrorMessage = NullIfEmpty(dto.ErrorMessage), + ErrorDetail = NullIfEmpty(dto.ErrorDetail), + RequestSummary = NullIfEmpty(dto.RequestSummary), + ResponseSummary = NullIfEmpty(dto.ResponseSummary), + PayloadTruncated = dto.PayloadTruncated, + Extra = NullIfEmpty(dto.Extra), + ForwardState = null, + }); + } + + var cmd = new IngestAuditEventsCommand(entities); + IngestAuditEventsReply reply; + try + { + reply = await actor.Ask( + cmd, AuditIngestAskTimeout, context.CancellationToken); + } + catch (Exception ex) + { + // Audit ingest is best-effort; failing this RPC at the gRPC layer + // would surface as a transport error and force the site to retry + // (which it would do anyway). Logging + an empty ack keeps the + // semantics consistent with the "wiring incomplete" path above. + _logger.LogError(ex, + "AuditLogIngestActor Ask failed for batch of {Count} events; returning empty ack.", + request.Events.Count); + return new IngestAck(); + } + + var ack = new IngestAck(); + foreach (var id in reply.AcceptedEventIds) + { + ack.AcceptedEventIds.Add(id.ToString()); + } + return ack; + } + + private static string? NullIfEmpty(string? value) => + string.IsNullOrEmpty(value) ? null : value; + /// /// Tracks a single active stream so cleanup only removes its own entry. /// diff --git a/src/ScadaLink.Communication/Protos/sitestream.proto b/src/ScadaLink.Communication/Protos/sitestream.proto index d86459c..d01852f 100644 --- a/src/ScadaLink.Communication/Protos/sitestream.proto +++ b/src/ScadaLink.Communication/Protos/sitestream.proto @@ -3,9 +3,11 @@ option csharp_namespace = "ScadaLink.Communication.Grpc"; package sitestream; import "google/protobuf/timestamp.proto"; +import "google/protobuf/wrappers.proto"; // Int32Value service SiteStreamService { rpc SubscribeInstance(InstanceStreamRequest) returns (stream SiteStreamEvent); + rpc IngestAuditEvents(AuditEventBatch) returns (IngestAck); } message InstanceStreamRequest { @@ -63,3 +65,31 @@ message AlarmStateUpdate { AlarmLevelEnum level = 6; // ALARM_LEVEL_NONE for binary trigger types; set by HiLo. string message = 7; // Optional per-band operator message; empty when unset. } + +// Audit Log (#23) telemetry: single lifecycle event ferried from a site SQLite +// hot-path row to central via IngestAuditEvents. Mirrors AuditEvent (Commons) +// minus the site-local ForwardState and the central IngestedAtUtc (set on ingest). +message AuditEventDto { + string event_id = 1; + google.protobuf.Timestamp occurred_at_utc = 2; + string channel = 3; + string kind = 4; + string correlation_id = 5; // empty string represents null + string source_site_id = 6; + string source_instance_id = 7; + string source_script = 8; + string actor = 9; + string target = 10; + string status = 11; + google.protobuf.Int32Value http_status = 12; // null when absent + google.protobuf.Int32Value duration_ms = 13; + string error_message = 14; + string error_detail = 15; + string request_summary = 16; + string response_summary = 17; + bool payload_truncated = 18; + string extra = 19; +} + +message AuditEventBatch { repeated AuditEventDto events = 1; } +message IngestAck { repeated string accepted_event_ids = 1; } diff --git a/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs b/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs index b1892bc..3a843eb 100644 --- a/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs +++ b/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs @@ -25,40 +25,59 @@ namespace ScadaLink.Communication.Grpc { byte[] descriptorData = global::System.Convert.FromBase64String( string.Concat( "ChdQcm90b3Mvc2l0ZXN0cmVhbS5wcm90bxIKc2l0ZXN0cmVhbRofZ29vZ2xl", - "L3Byb3RvYnVmL3RpbWVzdGFtcC5wcm90byJNChVJbnN0YW5jZVN0cmVhbVJl", - "cXVlc3QSFgoOY29ycmVsYXRpb25faWQYASABKAkSHAoUaW5zdGFuY2VfdW5p", - "cXVlX25hbWUYAiABKAkiqAEKD1NpdGVTdHJlYW1FdmVudBIWCg5jb3JyZWxh", - "dGlvbl9pZBgBIAEoCRI9ChFhdHRyaWJ1dGVfY2hhbmdlZBgCIAEoCzIgLnNp", - "dGVzdHJlYW0uQXR0cmlidXRlVmFsdWVVcGRhdGVIABI1Cg1hbGFybV9jaGFu", - "Z2VkGAMgASgLMhwuc2l0ZXN0cmVhbS5BbGFybVN0YXRlVXBkYXRlSABCBwoF", - "ZXZlbnQiyAEKFEF0dHJpYnV0ZVZhbHVlVXBkYXRlEhwKFGluc3RhbmNlX3Vu", - "aXF1ZV9uYW1lGAEgASgJEhYKDmF0dHJpYnV0ZV9wYXRoGAIgASgJEhYKDmF0", - "dHJpYnV0ZV9uYW1lGAMgASgJEg0KBXZhbHVlGAQgASgJEiQKB3F1YWxpdHkY", - "BSABKA4yEy5zaXRlc3RyZWFtLlF1YWxpdHkSLQoJdGltZXN0YW1wGAYgASgL", - "MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCLsAQoQQWxhcm1TdGF0ZVVw", - "ZGF0ZRIcChRpbnN0YW5jZV91bmlxdWVfbmFtZRgBIAEoCRISCgphbGFybV9u", - "YW1lGAIgASgJEikKBXN0YXRlGAMgASgOMhouc2l0ZXN0cmVhbS5BbGFybVN0", - "YXRlRW51bRIQCghwcmlvcml0eRgEIAEoBRItCgl0aW1lc3RhbXAYBSABKAsy", - "Gi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEikKBWxldmVsGAYgASgOMhou", - "c2l0ZXN0cmVhbS5BbGFybUxldmVsRW51bRIPCgdtZXNzYWdlGAcgASgJKlwK", - "B1F1YWxpdHkSFwoTUVVBTElUWV9VTlNQRUNJRklFRBAAEhAKDFFVQUxJVFlf", - "R09PRBABEhUKEVFVQUxJVFlfVU5DRVJUQUlOEAISDwoLUVVBTElUWV9CQUQQ", - "AypdCg5BbGFybVN0YXRlRW51bRIbChdBTEFSTV9TVEFURV9VTlNQRUNJRklF", - "RBAAEhYKEkFMQVJNX1NUQVRFX05PUk1BTBABEhYKEkFMQVJNX1NUQVRFX0FD", - "VElWRRACKoUBCg5BbGFybUxldmVsRW51bRIUChBBTEFSTV9MRVZFTF9OT05F", - "EAASEwoPQUxBUk1fTEVWRUxfTE9XEAESFwoTQUxBUk1fTEVWRUxfTE9XX0xP", - "VxACEhQKEEFMQVJNX0xFVkVMX0hJR0gQAxIZChVBTEFSTV9MRVZFTF9ISUdI", - "X0hJR0gQBDJqChFTaXRlU3RyZWFtU2VydmljZRJVChFTdWJzY3JpYmVJbnN0", - "YW5jZRIhLnNpdGVzdHJlYW0uSW5zdGFuY2VTdHJlYW1SZXF1ZXN0Ghsuc2l0", - "ZXN0cmVhbS5TaXRlU3RyZWFtRXZlbnQwAUIfqgIcU2NhZGFMaW5rLkNvbW11", - "bmljYXRpb24uR3JwY2IGcHJvdG8z")); + "L3Byb3RvYnVmL3RpbWVzdGFtcC5wcm90bxoeZ29vZ2xlL3Byb3RvYnVmL3dy", + "YXBwZXJzLnByb3RvIk0KFUluc3RhbmNlU3RyZWFtUmVxdWVzdBIWCg5jb3Jy", + "ZWxhdGlvbl9pZBgBIAEoCRIcChRpbnN0YW5jZV91bmlxdWVfbmFtZRgCIAEo", + "CSKoAQoPU2l0ZVN0cmVhbUV2ZW50EhYKDmNvcnJlbGF0aW9uX2lkGAEgASgJ", + "Ej0KEWF0dHJpYnV0ZV9jaGFuZ2VkGAIgASgLMiAuc2l0ZXN0cmVhbS5BdHRy", + "aWJ1dGVWYWx1ZVVwZGF0ZUgAEjUKDWFsYXJtX2NoYW5nZWQYAyABKAsyHC5z", + "aXRlc3RyZWFtLkFsYXJtU3RhdGVVcGRhdGVIAEIHCgVldmVudCLIAQoUQXR0", + "cmlidXRlVmFsdWVVcGRhdGUSHAoUaW5zdGFuY2VfdW5pcXVlX25hbWUYASAB", + "KAkSFgoOYXR0cmlidXRlX3BhdGgYAiABKAkSFgoOYXR0cmlidXRlX25hbWUY", + "AyABKAkSDQoFdmFsdWUYBCABKAkSJAoHcXVhbGl0eRgFIAEoDjITLnNpdGVz", + "dHJlYW0uUXVhbGl0eRItCgl0aW1lc3RhbXAYBiABKAsyGi5nb29nbGUucHJv", + "dG9idWYuVGltZXN0YW1wIuwBChBBbGFybVN0YXRlVXBkYXRlEhwKFGluc3Rh", + "bmNlX3VuaXF1ZV9uYW1lGAEgASgJEhIKCmFsYXJtX25hbWUYAiABKAkSKQoF", + "c3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVtEhAKCHBy", + "aW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90", + "b2J1Zi5UaW1lc3RhbXASKQoFbGV2ZWwYBiABKA4yGi5zaXRlc3RyZWFtLkFs", + "YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAki9QMKDUF1ZGl0RXZlbnRE", + "dG8SEAoIZXZlbnRfaWQYASABKAkSMwoPb2NjdXJyZWRfYXRfdXRjGAIgASgL", + "MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjaGFubmVsGAMgASgJ", + "EgwKBGtpbmQYBCABKAkSFgoOY29ycmVsYXRpb25faWQYBSABKAkSFgoOc291", + "cmNlX3NpdGVfaWQYBiABKAkSGgoSc291cmNlX2luc3RhbmNlX2lkGAcgASgJ", + "EhUKDXNvdXJjZV9zY3JpcHQYCCABKAkSDQoFYWN0b3IYCSABKAkSDgoGdGFy", + "Z2V0GAogASgJEg4KBnN0YXR1cxgLIAEoCRIwCgtodHRwX3N0YXR1cxgMIAEo", + "CzIbLmdvb2dsZS5wcm90b2J1Zi5JbnQzMlZhbHVlEjAKC2R1cmF0aW9uX21z", + "GA0gASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUSFQoNZXJyb3Jf", + "bWVzc2FnZRgOIAEoCRIUCgxlcnJvcl9kZXRhaWwYDyABKAkSFwoPcmVxdWVz", + "dF9zdW1tYXJ5GBAgASgJEhgKEHJlc3BvbnNlX3N1bW1hcnkYESABKAkSGQoR", + "cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkiPAoPQXVk", + "aXRFdmVudEJhdGNoEikKBmV2ZW50cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVk", + "aXRFdmVudER0byInCglJbmdlc3RBY2sSGgoSYWNjZXB0ZWRfZXZlbnRfaWRz", + "GAEgAygJKlwKB1F1YWxpdHkSFwoTUVVBTElUWV9VTlNQRUNJRklFRBAAEhAK", + "DFFVQUxJVFlfR09PRBABEhUKEVFVQUxJVFlfVU5DRVJUQUlOEAISDwoLUVVB", + "TElUWV9CQUQQAypdCg5BbGFybVN0YXRlRW51bRIbChdBTEFSTV9TVEFURV9V", + "TlNQRUNJRklFRBAAEhYKEkFMQVJNX1NUQVRFX05PUk1BTBABEhYKEkFMQVJN", + "X1NUQVRFX0FDVElWRRACKoUBCg5BbGFybUxldmVsRW51bRIUChBBTEFSTV9M", + "RVZFTF9OT05FEAASEwoPQUxBUk1fTEVWRUxfTE9XEAESFwoTQUxBUk1fTEVW", + "RUxfTE9XX0xPVxACEhQKEEFMQVJNX0xFVkVMX0hJR0gQAxIZChVBTEFSTV9M", + "RVZFTF9ISUdIX0hJR0gQBDKzAQoRU2l0ZVN0cmVhbVNlcnZpY2USVQoRU3Vi", + "c2NyaWJlSW5zdGFuY2USIS5zaXRlc3RyZWFtLkluc3RhbmNlU3RyZWFtUmVx", + "dWVzdBobLnNpdGVzdHJlYW0uU2l0ZVN0cmVhbUV2ZW50MAESRwoRSW5nZXN0", + "QXVkaXRFdmVudHMSGy5zaXRlc3RyZWFtLkF1ZGl0RXZlbnRCYXRjaBoVLnNp", + "dGVzdHJlYW0uSW5nZXN0QWNrQh+qAhxTY2FkYUxpbmsuQ29tbXVuaWNhdGlv", + "bi5HcnBjYgZwcm90bzM=")); descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, - new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, }, + new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, }, new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ScadaLink.Communication.Grpc.Quality), typeof(global::ScadaLink.Communication.Grpc.AlarmStateEnum), typeof(global::ScadaLink.Communication.Grpc.AlarmLevelEnum), }, null, new pbr::GeneratedClrTypeInfo[] { new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.InstanceStreamRequest), global::ScadaLink.Communication.Grpc.InstanceStreamRequest.Parser, new[]{ "CorrelationId", "InstanceUniqueName" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteStreamEvent), global::ScadaLink.Communication.Grpc.SiteStreamEvent.Parser, new[]{ "CorrelationId", "AttributeChanged", "AlarmChanged" }, new[]{ "Event" }, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AttributeValueUpdate), global::ScadaLink.Communication.Grpc.AttributeValueUpdate.Parser, new[]{ "InstanceUniqueName", "AttributePath", "AttributeName", "Value", "Quality", "Timestamp" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AlarmStateUpdate), global::ScadaLink.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp", "Level", "Message" }, null, null, null, null) + new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AlarmStateUpdate), global::ScadaLink.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp", "Level", "Message" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventDto), global::ScadaLink.Communication.Grpc.AuditEventDto.Parser, new[]{ "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventBatch), global::ScadaLink.Communication.Grpc.AuditEventBatch.Parser, new[]{ "Events" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.IngestAck), global::ScadaLink.Communication.Grpc.IngestAck.Parser, new[]{ "AcceptedEventIds" }, null, null, null, null) })); } #endregion @@ -1487,6 +1506,1280 @@ namespace ScadaLink.Communication.Grpc { } + /// + /// Audit Log (#23) telemetry: single lifecycle event ferried from a site SQLite + /// hot-path row to central via IngestAuditEvents. Mirrors AuditEvent (Commons) + /// minus the site-local ForwardState and the central IngestedAtUtc (set on ingest). + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class AuditEventDto : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new AuditEventDto()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ScadaLink.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[4]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public AuditEventDto() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public AuditEventDto(AuditEventDto other) : this() { + eventId_ = other.eventId_; + occurredAtUtc_ = other.occurredAtUtc_ != null ? other.occurredAtUtc_.Clone() : null; + channel_ = other.channel_; + kind_ = other.kind_; + correlationId_ = other.correlationId_; + sourceSiteId_ = other.sourceSiteId_; + sourceInstanceId_ = other.sourceInstanceId_; + sourceScript_ = other.sourceScript_; + actor_ = other.actor_; + target_ = other.target_; + status_ = other.status_; + HttpStatus = other.HttpStatus; + DurationMs = other.DurationMs; + errorMessage_ = other.errorMessage_; + errorDetail_ = other.errorDetail_; + requestSummary_ = other.requestSummary_; + responseSummary_ = other.responseSummary_; + payloadTruncated_ = other.payloadTruncated_; + extra_ = other.extra_; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public AuditEventDto Clone() { + return new AuditEventDto(this); + } + + /// Field number for the "event_id" field. + public const int EventIdFieldNumber = 1; + private string eventId_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string EventId { + get { return eventId_; } + set { + eventId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "occurred_at_utc" field. + public const int OccurredAtUtcFieldNumber = 2; + private global::Google.Protobuf.WellKnownTypes.Timestamp occurredAtUtc_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::Google.Protobuf.WellKnownTypes.Timestamp OccurredAtUtc { + get { return occurredAtUtc_; } + set { + occurredAtUtc_ = value; + } + } + + /// Field number for the "channel" field. + public const int ChannelFieldNumber = 3; + private string channel_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string Channel { + get { return channel_; } + set { + channel_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "kind" field. + public const int KindFieldNumber = 4; + private string kind_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string Kind { + get { return kind_; } + set { + kind_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "correlation_id" field. + public const int CorrelationIdFieldNumber = 5; + private string correlationId_ = ""; + /// + /// empty string represents null + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string CorrelationId { + get { return correlationId_; } + set { + correlationId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "source_site_id" field. + public const int SourceSiteIdFieldNumber = 6; + private string sourceSiteId_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string SourceSiteId { + get { return sourceSiteId_; } + set { + sourceSiteId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "source_instance_id" field. + public const int SourceInstanceIdFieldNumber = 7; + private string sourceInstanceId_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string SourceInstanceId { + get { return sourceInstanceId_; } + set { + sourceInstanceId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "source_script" field. + public const int SourceScriptFieldNumber = 8; + private string sourceScript_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string SourceScript { + get { return sourceScript_; } + set { + sourceScript_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "actor" field. + public const int ActorFieldNumber = 9; + private string actor_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string Actor { + get { return actor_; } + set { + actor_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "target" field. + public const int TargetFieldNumber = 10; + private string target_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string Target { + get { return target_; } + set { + target_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "status" field. + public const int StatusFieldNumber = 11; + private string status_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string Status { + get { return status_; } + set { + status_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "http_status" field. + public const int HttpStatusFieldNumber = 12; + private static readonly pb::FieldCodec _single_httpStatus_codec = pb::FieldCodec.ForStructWrapper(98); + private int? httpStatus_; + /// + /// null when absent + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int? HttpStatus { + get { return httpStatus_; } + set { + httpStatus_ = value; + } + } + + + /// Field number for the "duration_ms" field. + public const int DurationMsFieldNumber = 13; + private static readonly pb::FieldCodec _single_durationMs_codec = pb::FieldCodec.ForStructWrapper(106); + private int? durationMs_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int? DurationMs { + get { return durationMs_; } + set { + durationMs_ = value; + } + } + + + /// Field number for the "error_message" field. + public const int ErrorMessageFieldNumber = 14; + private string errorMessage_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string ErrorMessage { + get { return errorMessage_; } + set { + errorMessage_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "error_detail" field. + public const int ErrorDetailFieldNumber = 15; + private string errorDetail_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string ErrorDetail { + get { return errorDetail_; } + set { + errorDetail_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "request_summary" field. + public const int RequestSummaryFieldNumber = 16; + private string requestSummary_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string RequestSummary { + get { return requestSummary_; } + set { + requestSummary_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "response_summary" field. + public const int ResponseSummaryFieldNumber = 17; + private string responseSummary_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string ResponseSummary { + get { return responseSummary_; } + set { + responseSummary_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "payload_truncated" field. + public const int PayloadTruncatedFieldNumber = 18; + private bool payloadTruncated_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool PayloadTruncated { + get { return payloadTruncated_; } + set { + payloadTruncated_ = value; + } + } + + /// Field number for the "extra" field. + public const int ExtraFieldNumber = 19; + private string extra_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string Extra { + get { return extra_; } + set { + extra_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as AuditEventDto); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(AuditEventDto other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (EventId != other.EventId) return false; + if (!object.Equals(OccurredAtUtc, other.OccurredAtUtc)) return false; + if (Channel != other.Channel) return false; + if (Kind != other.Kind) return false; + if (CorrelationId != other.CorrelationId) return false; + if (SourceSiteId != other.SourceSiteId) return false; + if (SourceInstanceId != other.SourceInstanceId) return false; + if (SourceScript != other.SourceScript) return false; + if (Actor != other.Actor) return false; + if (Target != other.Target) return false; + if (Status != other.Status) return false; + if (HttpStatus != other.HttpStatus) return false; + if (DurationMs != other.DurationMs) return false; + if (ErrorMessage != other.ErrorMessage) return false; + if (ErrorDetail != other.ErrorDetail) return false; + if (RequestSummary != other.RequestSummary) return false; + if (ResponseSummary != other.ResponseSummary) return false; + if (PayloadTruncated != other.PayloadTruncated) return false; + if (Extra != other.Extra) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (EventId.Length != 0) hash ^= EventId.GetHashCode(); + if (occurredAtUtc_ != null) hash ^= OccurredAtUtc.GetHashCode(); + if (Channel.Length != 0) hash ^= Channel.GetHashCode(); + if (Kind.Length != 0) hash ^= Kind.GetHashCode(); + if (CorrelationId.Length != 0) hash ^= CorrelationId.GetHashCode(); + if (SourceSiteId.Length != 0) hash ^= SourceSiteId.GetHashCode(); + if (SourceInstanceId.Length != 0) hash ^= SourceInstanceId.GetHashCode(); + if (SourceScript.Length != 0) hash ^= SourceScript.GetHashCode(); + if (Actor.Length != 0) hash ^= Actor.GetHashCode(); + if (Target.Length != 0) hash ^= Target.GetHashCode(); + if (Status.Length != 0) hash ^= Status.GetHashCode(); + if (httpStatus_ != null) hash ^= HttpStatus.GetHashCode(); + if (durationMs_ != null) hash ^= DurationMs.GetHashCode(); + if (ErrorMessage.Length != 0) hash ^= ErrorMessage.GetHashCode(); + if (ErrorDetail.Length != 0) hash ^= ErrorDetail.GetHashCode(); + if (RequestSummary.Length != 0) hash ^= RequestSummary.GetHashCode(); + if (ResponseSummary.Length != 0) hash ^= ResponseSummary.GetHashCode(); + if (PayloadTruncated != false) hash ^= PayloadTruncated.GetHashCode(); + if (Extra.Length != 0) hash ^= Extra.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (EventId.Length != 0) { + output.WriteRawTag(10); + output.WriteString(EventId); + } + if (occurredAtUtc_ != null) { + output.WriteRawTag(18); + output.WriteMessage(OccurredAtUtc); + } + if (Channel.Length != 0) { + output.WriteRawTag(26); + output.WriteString(Channel); + } + if (Kind.Length != 0) { + output.WriteRawTag(34); + output.WriteString(Kind); + } + if (CorrelationId.Length != 0) { + output.WriteRawTag(42); + output.WriteString(CorrelationId); + } + if (SourceSiteId.Length != 0) { + output.WriteRawTag(50); + output.WriteString(SourceSiteId); + } + if (SourceInstanceId.Length != 0) { + output.WriteRawTag(58); + output.WriteString(SourceInstanceId); + } + if (SourceScript.Length != 0) { + output.WriteRawTag(66); + output.WriteString(SourceScript); + } + if (Actor.Length != 0) { + output.WriteRawTag(74); + output.WriteString(Actor); + } + if (Target.Length != 0) { + output.WriteRawTag(82); + output.WriteString(Target); + } + if (Status.Length != 0) { + output.WriteRawTag(90); + output.WriteString(Status); + } + if (httpStatus_ != null) { + _single_httpStatus_codec.WriteTagAndValue(output, HttpStatus); + } + if (durationMs_ != null) { + _single_durationMs_codec.WriteTagAndValue(output, DurationMs); + } + if (ErrorMessage.Length != 0) { + output.WriteRawTag(114); + output.WriteString(ErrorMessage); + } + if (ErrorDetail.Length != 0) { + output.WriteRawTag(122); + output.WriteString(ErrorDetail); + } + if (RequestSummary.Length != 0) { + output.WriteRawTag(130, 1); + output.WriteString(RequestSummary); + } + if (ResponseSummary.Length != 0) { + output.WriteRawTag(138, 1); + output.WriteString(ResponseSummary); + } + if (PayloadTruncated != false) { + output.WriteRawTag(144, 1); + output.WriteBool(PayloadTruncated); + } + if (Extra.Length != 0) { + output.WriteRawTag(154, 1); + output.WriteString(Extra); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (EventId.Length != 0) { + output.WriteRawTag(10); + output.WriteString(EventId); + } + if (occurredAtUtc_ != null) { + output.WriteRawTag(18); + output.WriteMessage(OccurredAtUtc); + } + if (Channel.Length != 0) { + output.WriteRawTag(26); + output.WriteString(Channel); + } + if (Kind.Length != 0) { + output.WriteRawTag(34); + output.WriteString(Kind); + } + if (CorrelationId.Length != 0) { + output.WriteRawTag(42); + output.WriteString(CorrelationId); + } + if (SourceSiteId.Length != 0) { + output.WriteRawTag(50); + output.WriteString(SourceSiteId); + } + if (SourceInstanceId.Length != 0) { + output.WriteRawTag(58); + output.WriteString(SourceInstanceId); + } + if (SourceScript.Length != 0) { + output.WriteRawTag(66); + output.WriteString(SourceScript); + } + if (Actor.Length != 0) { + output.WriteRawTag(74); + output.WriteString(Actor); + } + if (Target.Length != 0) { + output.WriteRawTag(82); + output.WriteString(Target); + } + if (Status.Length != 0) { + output.WriteRawTag(90); + output.WriteString(Status); + } + if (httpStatus_ != null) { + _single_httpStatus_codec.WriteTagAndValue(ref output, HttpStatus); + } + if (durationMs_ != null) { + _single_durationMs_codec.WriteTagAndValue(ref output, DurationMs); + } + if (ErrorMessage.Length != 0) { + output.WriteRawTag(114); + output.WriteString(ErrorMessage); + } + if (ErrorDetail.Length != 0) { + output.WriteRawTag(122); + output.WriteString(ErrorDetail); + } + if (RequestSummary.Length != 0) { + output.WriteRawTag(130, 1); + output.WriteString(RequestSummary); + } + if (ResponseSummary.Length != 0) { + output.WriteRawTag(138, 1); + output.WriteString(ResponseSummary); + } + if (PayloadTruncated != false) { + output.WriteRawTag(144, 1); + output.WriteBool(PayloadTruncated); + } + if (Extra.Length != 0) { + output.WriteRawTag(154, 1); + output.WriteString(Extra); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (EventId.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(EventId); + } + if (occurredAtUtc_ != null) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(OccurredAtUtc); + } + if (Channel.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(Channel); + } + if (Kind.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(Kind); + } + if (CorrelationId.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(CorrelationId); + } + if (SourceSiteId.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(SourceSiteId); + } + if (SourceInstanceId.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(SourceInstanceId); + } + if (SourceScript.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(SourceScript); + } + if (Actor.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(Actor); + } + if (Target.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(Target); + } + if (Status.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(Status); + } + if (httpStatus_ != null) { + size += _single_httpStatus_codec.CalculateSizeWithTag(HttpStatus); + } + if (durationMs_ != null) { + size += _single_durationMs_codec.CalculateSizeWithTag(DurationMs); + } + if (ErrorMessage.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(ErrorMessage); + } + if (ErrorDetail.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(ErrorDetail); + } + if (RequestSummary.Length != 0) { + size += 2 + pb::CodedOutputStream.ComputeStringSize(RequestSummary); + } + if (ResponseSummary.Length != 0) { + size += 2 + pb::CodedOutputStream.ComputeStringSize(ResponseSummary); + } + if (PayloadTruncated != false) { + size += 2 + 1; + } + if (Extra.Length != 0) { + size += 2 + pb::CodedOutputStream.ComputeStringSize(Extra); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(AuditEventDto other) { + if (other == null) { + return; + } + if (other.EventId.Length != 0) { + EventId = other.EventId; + } + if (other.occurredAtUtc_ != null) { + if (occurredAtUtc_ == null) { + OccurredAtUtc = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + OccurredAtUtc.MergeFrom(other.OccurredAtUtc); + } + if (other.Channel.Length != 0) { + Channel = other.Channel; + } + if (other.Kind.Length != 0) { + Kind = other.Kind; + } + if (other.CorrelationId.Length != 0) { + CorrelationId = other.CorrelationId; + } + if (other.SourceSiteId.Length != 0) { + SourceSiteId = other.SourceSiteId; + } + if (other.SourceInstanceId.Length != 0) { + SourceInstanceId = other.SourceInstanceId; + } + if (other.SourceScript.Length != 0) { + SourceScript = other.SourceScript; + } + if (other.Actor.Length != 0) { + Actor = other.Actor; + } + if (other.Target.Length != 0) { + Target = other.Target; + } + if (other.Status.Length != 0) { + Status = other.Status; + } + if (other.httpStatus_ != null) { + if (httpStatus_ == null || other.HttpStatus != 0) { + HttpStatus = other.HttpStatus; + } + } + if (other.durationMs_ != null) { + if (durationMs_ == null || other.DurationMs != 0) { + DurationMs = other.DurationMs; + } + } + if (other.ErrorMessage.Length != 0) { + ErrorMessage = other.ErrorMessage; + } + if (other.ErrorDetail.Length != 0) { + ErrorDetail = other.ErrorDetail; + } + if (other.RequestSummary.Length != 0) { + RequestSummary = other.RequestSummary; + } + if (other.ResponseSummary.Length != 0) { + ResponseSummary = other.ResponseSummary; + } + if (other.PayloadTruncated != false) { + PayloadTruncated = other.PayloadTruncated; + } + if (other.Extra.Length != 0) { + Extra = other.Extra; + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + EventId = input.ReadString(); + break; + } + case 18: { + if (occurredAtUtc_ == null) { + OccurredAtUtc = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + input.ReadMessage(OccurredAtUtc); + break; + } + case 26: { + Channel = input.ReadString(); + break; + } + case 34: { + Kind = input.ReadString(); + break; + } + case 42: { + CorrelationId = input.ReadString(); + break; + } + case 50: { + SourceSiteId = input.ReadString(); + break; + } + case 58: { + SourceInstanceId = input.ReadString(); + break; + } + case 66: { + SourceScript = input.ReadString(); + break; + } + case 74: { + Actor = input.ReadString(); + break; + } + case 82: { + Target = input.ReadString(); + break; + } + case 90: { + Status = input.ReadString(); + break; + } + case 98: { + int? value = _single_httpStatus_codec.Read(input); + if (httpStatus_ == null || value != 0) { + HttpStatus = value; + } + break; + } + case 106: { + int? value = _single_durationMs_codec.Read(input); + if (durationMs_ == null || value != 0) { + DurationMs = value; + } + break; + } + case 114: { + ErrorMessage = input.ReadString(); + break; + } + case 122: { + ErrorDetail = input.ReadString(); + break; + } + case 130: { + RequestSummary = input.ReadString(); + break; + } + case 138: { + ResponseSummary = input.ReadString(); + break; + } + case 144: { + PayloadTruncated = input.ReadBool(); + break; + } + case 154: { + Extra = input.ReadString(); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + EventId = input.ReadString(); + break; + } + case 18: { + if (occurredAtUtc_ == null) { + OccurredAtUtc = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + input.ReadMessage(OccurredAtUtc); + break; + } + case 26: { + Channel = input.ReadString(); + break; + } + case 34: { + Kind = input.ReadString(); + break; + } + case 42: { + CorrelationId = input.ReadString(); + break; + } + case 50: { + SourceSiteId = input.ReadString(); + break; + } + case 58: { + SourceInstanceId = input.ReadString(); + break; + } + case 66: { + SourceScript = input.ReadString(); + break; + } + case 74: { + Actor = input.ReadString(); + break; + } + case 82: { + Target = input.ReadString(); + break; + } + case 90: { + Status = input.ReadString(); + break; + } + case 98: { + int? value = _single_httpStatus_codec.Read(ref input); + if (httpStatus_ == null || value != 0) { + HttpStatus = value; + } + break; + } + case 106: { + int? value = _single_durationMs_codec.Read(ref input); + if (durationMs_ == null || value != 0) { + DurationMs = value; + } + break; + } + case 114: { + ErrorMessage = input.ReadString(); + break; + } + case 122: { + ErrorDetail = input.ReadString(); + break; + } + case 130: { + RequestSummary = input.ReadString(); + break; + } + case 138: { + ResponseSummary = input.ReadString(); + break; + } + case 144: { + PayloadTruncated = input.ReadBool(); + break; + } + case 154: { + Extra = input.ReadString(); + break; + } + } + } + } + #endif + + } + + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class AuditEventBatch : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new AuditEventBatch()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ScadaLink.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[5]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public AuditEventBatch() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public AuditEventBatch(AuditEventBatch other) : this() { + events_ = other.events_.Clone(); + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public AuditEventBatch Clone() { + return new AuditEventBatch(this); + } + + /// Field number for the "events" field. + public const int EventsFieldNumber = 1; + private static readonly pb::FieldCodec _repeated_events_codec + = pb::FieldCodec.ForMessage(10, global::ScadaLink.Communication.Grpc.AuditEventDto.Parser); + private readonly pbc::RepeatedField events_ = new pbc::RepeatedField(); + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::RepeatedField Events { + get { return events_; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as AuditEventBatch); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(AuditEventBatch other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if(!events_.Equals(other.events_)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + hash ^= events_.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + events_.WriteTo(output, _repeated_events_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + events_.WriteTo(ref output, _repeated_events_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + size += events_.CalculateSize(_repeated_events_codec); + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(AuditEventBatch other) { + if (other == null) { + return; + } + events_.Add(other.events_); + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + events_.AddEntriesFrom(input, _repeated_events_codec); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + events_.AddEntriesFrom(ref input, _repeated_events_codec); + break; + } + } + } + } + #endif + + } + + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class IngestAck : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new IngestAck()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ScadaLink.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[6]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public IngestAck() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public IngestAck(IngestAck other) : this() { + acceptedEventIds_ = other.acceptedEventIds_.Clone(); + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public IngestAck Clone() { + return new IngestAck(this); + } + + /// Field number for the "accepted_event_ids" field. + public const int AcceptedEventIdsFieldNumber = 1; + private static readonly pb::FieldCodec _repeated_acceptedEventIds_codec + = pb::FieldCodec.ForString(10); + private readonly pbc::RepeatedField acceptedEventIds_ = new pbc::RepeatedField(); + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::RepeatedField AcceptedEventIds { + get { return acceptedEventIds_; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as IngestAck); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(IngestAck other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if(!acceptedEventIds_.Equals(other.acceptedEventIds_)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + hash ^= acceptedEventIds_.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + acceptedEventIds_.WriteTo(output, _repeated_acceptedEventIds_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + acceptedEventIds_.WriteTo(ref output, _repeated_acceptedEventIds_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + size += acceptedEventIds_.CalculateSize(_repeated_acceptedEventIds_codec); + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(IngestAck other) { + if (other == null) { + return; + } + acceptedEventIds_.Add(other.acceptedEventIds_); + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + acceptedEventIds_.AddEntriesFrom(input, _repeated_acceptedEventIds_codec); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + acceptedEventIds_.AddEntriesFrom(ref input, _repeated_acceptedEventIds_codec); + break; + } + } + } + } + #endif + + } + #endregion } diff --git a/src/ScadaLink.Communication/SiteStreamGrpc/SitestreamGrpc.cs b/src/ScadaLink.Communication/SiteStreamGrpc/SitestreamGrpc.cs index 6aa6ecb..0a900cb 100644 --- a/src/ScadaLink.Communication/SiteStreamGrpc/SitestreamGrpc.cs +++ b/src/ScadaLink.Communication/SiteStreamGrpc/SitestreamGrpc.cs @@ -49,6 +49,10 @@ namespace ScadaLink.Communication.Grpc { static readonly grpc::Marshaller __Marshaller_sitestream_InstanceStreamRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ScadaLink.Communication.Grpc.InstanceStreamRequest.Parser)); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] static readonly grpc::Marshaller __Marshaller_sitestream_SiteStreamEvent = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ScadaLink.Communication.Grpc.SiteStreamEvent.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_sitestream_AuditEventBatch = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ScadaLink.Communication.Grpc.AuditEventBatch.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_sitestream_IngestAck = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ScadaLink.Communication.Grpc.IngestAck.Parser)); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] static readonly grpc::Method __Method_SubscribeInstance = new grpc::Method( @@ -58,6 +62,14 @@ namespace ScadaLink.Communication.Grpc { __Marshaller_sitestream_InstanceStreamRequest, __Marshaller_sitestream_SiteStreamEvent); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Method __Method_IngestAuditEvents = new grpc::Method( + grpc::MethodType.Unary, + __ServiceName, + "IngestAuditEvents", + __Marshaller_sitestream_AuditEventBatch, + __Marshaller_sitestream_IngestAck); + /// Service descriptor public static global::Google.Protobuf.Reflection.ServiceDescriptor Descriptor { @@ -74,6 +86,12 @@ namespace ScadaLink.Communication.Grpc { throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::System.Threading.Tasks.Task IngestAuditEvents(global::ScadaLink.Communication.Grpc.AuditEventBatch request, grpc::ServerCallContext context) + { + throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); + } + } /// Client for SiteStreamService @@ -113,6 +131,26 @@ namespace ScadaLink.Communication.Grpc { { return CallInvoker.AsyncServerStreamingCall(__Method_SubscribeInstance, null, options, request); } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ScadaLink.Communication.Grpc.IngestAck IngestAuditEvents(global::ScadaLink.Communication.Grpc.AuditEventBatch request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return IngestAuditEvents(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ScadaLink.Communication.Grpc.IngestAck IngestAuditEvents(global::ScadaLink.Communication.Grpc.AuditEventBatch request, grpc::CallOptions options) + { + return CallInvoker.BlockingUnaryCall(__Method_IngestAuditEvents, null, options, request); + } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall IngestAuditEventsAsync(global::ScadaLink.Communication.Grpc.AuditEventBatch request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return IngestAuditEventsAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall IngestAuditEventsAsync(global::ScadaLink.Communication.Grpc.AuditEventBatch request, grpc::CallOptions options) + { + return CallInvoker.AsyncUnaryCall(__Method_IngestAuditEvents, null, options, request); + } /// Creates a new instance of client from given ClientBaseConfiguration. [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] protected override SiteStreamServiceClient NewInstance(ClientBaseConfiguration configuration) @@ -127,7 +165,8 @@ namespace ScadaLink.Communication.Grpc { public static grpc::ServerServiceDefinition BindService(SiteStreamServiceBase serviceImpl) { return grpc::ServerServiceDefinition.CreateBuilder() - .AddMethod(__Method_SubscribeInstance, serviceImpl.SubscribeInstance).Build(); + .AddMethod(__Method_SubscribeInstance, serviceImpl.SubscribeInstance) + .AddMethod(__Method_IngestAuditEvents, serviceImpl.IngestAuditEvents).Build(); } /// Register service method with a service binder with or without implementation. Useful when customizing the service binding logic. @@ -138,6 +177,7 @@ namespace ScadaLink.Communication.Grpc { public static void BindService(grpc::ServiceBinderBase serviceBinder, SiteStreamServiceBase serviceImpl) { serviceBinder.AddMethod(__Method_SubscribeInstance, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod(serviceImpl.SubscribeInstance)); + serviceBinder.AddMethod(__Method_IngestAuditEvents, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.IngestAuditEvents)); } } diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs index cf5682f..d88271f 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -1,4 +1,7 @@ +using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Types.Audit; @@ -12,11 +15,22 @@ namespace ScadaLink.ConfigurationDatabase.Repositories; /// public class AuditLogRepository : IAuditLogRepository { - private readonly ScadaLinkDbContext _context; + // SQL Server error numbers for duplicate-key violations on + // UX_AuditLog_EventId. 2601 is a unique-index violation; 2627 is a + // primary-key/unique-constraint violation. The IF NOT EXISTS … INSERT + // pattern has a check-then-act race window — two sessions can both pass + // the EXISTS check and then both attempt the INSERT — and the loser + // surfaces as one of these errors. Idempotency demands we swallow them. + private const int SqlErrorUniqueIndexViolation = 2601; + private const int SqlErrorPrimaryKeyViolation = 2627; - public AuditLogRepository(ScadaLinkDbContext context) + private readonly ScadaLinkDbContext _context; + private readonly ILogger _logger; + + public AuditLogRepository(ScadaLinkDbContext context, ILogger? logger = null) { _context = context ?? throw new ArgumentNullException(nameof(context)); + _logger = logger ?? NullLogger.Instance; } /// @@ -44,8 +58,10 @@ public class AuditLogRepository : IAuditLogRepository // FormattableString interpolation parameterises every value (no concatenation), // so this is safe against injection even for the string columns. - await _context.Database.ExecuteSqlInterpolatedAsync( - $@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId}) + try + { + await _context.Database.ExecuteSqlInterpolatedAsync( + $@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId}) INSERT INTO dbo.AuditLog (EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status, @@ -56,7 +72,24 @@ VALUES {evt.SourceSiteId}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status}, {evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary}, {evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});", - ct); + ct); + } + catch (SqlException ex) when ( + ex.Number == SqlErrorUniqueIndexViolation + || ex.Number == SqlErrorPrimaryKeyViolation) + { + // Two concurrent sessions both passed the IF NOT EXISTS check and + // both attempted the INSERT — the loser raises 2601/2627 against + // UX_AuditLog_EventId. First-write-wins idempotency is already the + // documented contract for this method, so the race outcome is + // semantically a no-op. Swallow at Debug; other SqlExceptions + // bubble. + _logger.LogDebug( + ex, + "InsertIfNotExistsAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for EventId {EventId}; treating as no-op.", + ex.Number, + evt.EventId); + } } /// diff --git a/src/ScadaLink.HealthMonitoring/ISiteHealthCollector.cs b/src/ScadaLink.HealthMonitoring/ISiteHealthCollector.cs index 1210833..c16c45f 100644 --- a/src/ScadaLink.HealthMonitoring/ISiteHealthCollector.cs +++ b/src/ScadaLink.HealthMonitoring/ISiteHealthCollector.cs @@ -12,6 +12,13 @@ public interface ISiteHealthCollector void IncrementScriptError(); void IncrementAlarmError(); void IncrementDeadLetter(); + /// + /// Audit Log (#23) Bundle G — increment the per-interval count of + /// FallbackAuditWriter primary failures. Bridged from the + /// IAuditWriteFailureCounter binding registered via + /// AddAuditLogHealthMetricsBridge(). + /// + void IncrementSiteAuditWriteFailures(); void UpdateConnectionHealth(string connectionName, ConnectionHealth health); void RemoveConnection(string connectionName); void UpdateTagResolution(string connectionName, int totalSubscribed, int successfullyResolved); diff --git a/src/ScadaLink.HealthMonitoring/SiteHealthCollector.cs b/src/ScadaLink.HealthMonitoring/SiteHealthCollector.cs index ca05cf9..1a6aa48 100644 --- a/src/ScadaLink.HealthMonitoring/SiteHealthCollector.cs +++ b/src/ScadaLink.HealthMonitoring/SiteHealthCollector.cs @@ -13,6 +13,7 @@ public class SiteHealthCollector : ISiteHealthCollector private int _scriptErrorCount; private int _alarmErrorCount; private int _deadLetterCount; + private int _siteAuditWriteFailures; private readonly ConcurrentDictionary _connectionStatuses = new(); private readonly ConcurrentDictionary _tagResolutionCounts = new(); private readonly ConcurrentDictionary _connectionEndpoints = new(); @@ -61,6 +62,18 @@ public class SiteHealthCollector : ISiteHealthCollector Interlocked.Increment(ref _deadLetterCount); } + /// + /// Audit Log (#23) Bundle G — increment the per-interval count of + /// FallbackAuditWriter primary failures. Bridged from the + /// IAuditWriteFailureCounter binding registered via + /// AddAuditLogHealthMetricsBridge(); reset every interval together + /// with the other per-interval counters. + /// + public void IncrementSiteAuditWriteFailures() + { + Interlocked.Increment(ref _siteAuditWriteFailures); + } + /// /// Update the health status for a named data connection. /// Called by DCL when connection state changes. @@ -144,6 +157,7 @@ public class SiteHealthCollector : ISiteHealthCollector var scriptErrors = Interlocked.Exchange(ref _scriptErrorCount, 0); var alarmErrors = Interlocked.Exchange(ref _alarmErrorCount, 0); var deadLetters = Interlocked.Exchange(ref _deadLetterCount, 0); + var siteAuditWriteFailures = Interlocked.Exchange(ref _siteAuditWriteFailures, 0); // Snapshot current connection and tag resolution state var connectionStatuses = new Dictionary(_connectionStatuses); @@ -175,6 +189,7 @@ public class SiteHealthCollector : ISiteHealthCollector DataConnectionEndpoints: connectionEndpoints, DataConnectionTagQuality: tagQuality, ParkedMessageCount: Interlocked.CompareExchange(ref _parkedMessageCount, 0, 0), - ClusterNodes: _clusterNodes?.ToList()); + ClusterNodes: _clusterNodes?.ToList(), + SiteAuditWriteFailures: siteAuditWriteFailures); } } diff --git a/src/ScadaLink.Host/Actors/AkkaHostedService.cs b/src/ScadaLink.Host/Actors/AkkaHostedService.cs index c0711d8..5bc5c7e 100644 --- a/src/ScadaLink.Host/Actors/AkkaHostedService.cs +++ b/src/ScadaLink.Host/Actors/AkkaHostedService.cs @@ -128,6 +128,13 @@ public class AkkaHostedService : IHostedService var rolesStr = string.Join(",", roles.Select(QuoteHocon)); return $@" +audit-telemetry-dispatcher {{ + type = ForkJoinDispatcher + throughput = 100 + dedicated-thread-pool {{ + thread-count = 2 + }} +}} akka {{ extensions = [ ""Akka.Cluster.Tools.PublishSubscribe.DistributedPubSubExtensionProvider, Akka.Cluster.Tools"" @@ -294,6 +301,47 @@ akka {{ commService?.SetNotificationOutbox(outboxProxy); _logger.LogInformation("NotificationOutbox singleton created and registered with CentralCommunicationActor"); + // Audit Log (#23) — central singleton mirrors the Notification Outbox + // pattern. The IngestAuditEvents gRPC handler lives on SiteStreamGrpcServer + // (Communication.Grpc); a central node hosting that server (M6 reconciliation + // path) hands the proxy in via SetAuditIngestActor below. When the gRPC + // server is not registered (current central topology), the host still + // brings the singleton up so a Bundle H in-process test (or a future + // direct caller) can Ask the proxy without further wiring. + // IAuditLogRepository is a SCOPED EF Core service, so the singleton + // actor takes the root IServiceProvider and creates a fresh scope per + // message (mirroring NotificationOutboxActor). Pre-resolving the + // repository here would attempt to take a scoped service from the + // root and fail under DI scope validation. + var auditIngestLogger = _serviceProvider.GetRequiredService() + .CreateLogger(); + + var auditIngestSingletonProps = ClusterSingletonManager.Props( + singletonProps: Props.Create(() => new ScadaLink.AuditLog.Central.AuditLogIngestActor( + _serviceProvider, + auditIngestLogger)), + terminationMessage: PoisonPill.Instance, + settings: ClusterSingletonManagerSettings.Create(_actorSystem!) + .WithSingletonName("audit-log-ingest")); + _actorSystem!.ActorOf(auditIngestSingletonProps, "audit-log-ingest-singleton"); + + var auditIngestProxyProps = ClusterSingletonProxy.Props( + singletonManagerPath: "/user/audit-log-ingest-singleton", + settings: ClusterSingletonProxySettings.Create(_actorSystem) + .WithSingletonName("audit-log-ingest")); + var auditIngestProxy = _actorSystem.ActorOf(auditIngestProxyProps, "audit-log-ingest-proxy"); + + // Hand the proxy to the SiteStreamGrpcServer (if registered on this node) + // so the IngestAuditEvents RPC routes incoming site batches to the singleton. + // The gRPC server is currently only registered on Site nodes; on a central + // node this resolves to null and the wiring is a no-op until M6 (which + // brings central-hosted gRPC + a real site→central client). + var grpcServer = _serviceProvider.GetService(); + grpcServer?.SetAuditIngestActor(auditIngestProxy); + _logger.LogInformation( + "AuditLogIngestActor singleton created (gRPC server bound: {GrpcBound})", + grpcServer is not null); + _logger.LogInformation("Central actors registered. CentralCommunicationActor created."); } @@ -504,6 +552,41 @@ akka {{ contacts.Count, _nodeOptions.SiteId); } + // Audit Log (#23) — site-side telemetry actor that drains the SQLite + // Pending queue and pushes to central via IngestAuditEvents. Not a + // cluster singleton: each site is its own cluster, and the actor reads + // node-local SQLite (no replication). The Props are bound to the + // dedicated audit-telemetry-dispatcher (defined in BuildHocon) so a + // batch SQLite read + gRPC push never contend with the default + // dispatcher used by hot-path actors. + // + // Per Bundle E's brief: the SiteAuditTelemetryActor takes its + // collaborators through its constructor, so we resolve them from DI + // and pass them in via Props.Create rather than relying on a future + // FactoryProvider. This also lets the M6 follow-up swap the + // NoOpSiteStreamAuditClient registration for the real gRPC client + // without touching this site wiring. + var siteAuditOptions = _serviceProvider + .GetRequiredService>(); + var siteAuditQueue = _serviceProvider + .GetRequiredService(); + var siteAuditClient = _serviceProvider + .GetRequiredService(); + var siteAuditLogger = _serviceProvider.GetRequiredService() + .CreateLogger(); + + var siteAuditTelemetryProps = Props.Create(() => + new ScadaLink.AuditLog.Site.Telemetry.SiteAuditTelemetryActor( + siteAuditQueue, + siteAuditClient, + siteAuditOptions, + siteAuditLogger)) + .WithDispatcher("audit-telemetry-dispatcher"); + _actorSystem.ActorOf(siteAuditTelemetryProps, "site-audit-telemetry"); + _logger.LogInformation( + "SiteAuditTelemetryActor created (dispatcher=audit-telemetry-dispatcher, client={ClientType})", + siteAuditClient.GetType().Name); + // Gate gRPC subscriptions until the actor system and SiteStreamManager are // initialized (REQ-HOST-7). // diff --git a/src/ScadaLink.Host/Program.cs b/src/ScadaLink.Host/Program.cs index 58d7ba7..a42f93d 100644 --- a/src/ScadaLink.Host/Program.cs +++ b/src/ScadaLink.Host/Program.cs @@ -1,5 +1,6 @@ using HealthChecks.UI.Client; using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using ScadaLink.AuditLog; using ScadaLink.CentralUI; using ScadaLink.ClusterInfrastructure; using ScadaLink.Communication; @@ -77,6 +78,10 @@ try // AddNotificationService() SMTP machinery above. AddNotificationOutbox binds // NotificationOutboxOptions via BindConfiguration, so no explicit Configure is needed. builder.Services.AddNotificationOutbox(); + // Audit Log (#23) — central node owns the AuditLogIngestActor singleton + + // IAuditLogRepository. The site writer chain is still registered (lazy + // singletons) but is never resolved on a central node. + builder.Services.AddAuditLog(builder.Configuration); builder.Services.AddTemplateEngine(); builder.Services.AddDeploymentManager(); builder.Services.AddSecurity(); diff --git a/src/ScadaLink.Host/ScadaLink.Host.csproj b/src/ScadaLink.Host/ScadaLink.Host.csproj index 71f7406..4b45288 100644 --- a/src/ScadaLink.Host/ScadaLink.Host.csproj +++ b/src/ScadaLink.Host/ScadaLink.Host.csproj @@ -38,6 +38,7 @@ + diff --git a/src/ScadaLink.Host/SiteServiceRegistration.cs b/src/ScadaLink.Host/SiteServiceRegistration.cs index 5e9dc50..dd13484 100644 --- a/src/ScadaLink.Host/SiteServiceRegistration.cs +++ b/src/ScadaLink.Host/SiteServiceRegistration.cs @@ -1,3 +1,4 @@ +using ScadaLink.AuditLog; using ScadaLink.ClusterInfrastructure; using ScadaLink.Communication; using ScadaLink.DataConnectionLayer; @@ -44,6 +45,19 @@ public static class SiteServiceRegistration services.AddStoreAndForward(); services.AddSiteEventLogging(); + // Audit Log (#23) — site-side hot-path writer + telemetry collaborators. + // The SiteAuditTelemetryActor itself is registered by AkkaHostedService + // in the site-role block; this call wires every DI dependency it (and + // ScriptRuntimeContext, when Bundle F lands) reaches for. + services.AddAuditLog(config); + + // Audit Log (#23) M2 Bundle G — bridge FallbackAuditWriter primary + // failures into the site health report payload as + // SiteAuditWriteFailures. Must come AFTER both AddSiteHealthMonitoring + // (registers ISiteHealthCollector) and AddAuditLog (registers the + // NoOp default this call replaces). + services.AddAuditLogHealthMetricsBridge(); + // WP-13: Akka.NET bootstrap via hosted service services.AddSingleton(); services.AddHostedService(sp => sp.GetRequiredService()); diff --git a/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs b/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs index 85cea1c..a812158 100644 --- a/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs @@ -101,6 +101,10 @@ public class ScriptExecutionActor : ReceiveActor // provider supplies the site id stamped on enqueued notifications. StoreAndForwardService? storeAndForward = null; var siteId = string.Empty; + // Audit Log #23 (M2 Bundle F): the writer is a singleton (FallbackAuditWriter + // composes the SQLite hot-path + drop-oldest ring); null in tests / hosts + // that haven't called AddAuditLog, which the helper handles as a no-op. + IAuditWriter? auditWriter = null; if (serviceProvider != null) { @@ -110,6 +114,7 @@ public class ScriptExecutionActor : ReceiveActor storeAndForward = serviceScope.ServiceProvider.GetService(); siteId = serviceScope.ServiceProvider.GetService()?.SiteId ?? string.Empty; + auditWriter = serviceScope.ServiceProvider.GetService(); } var context = new ScriptRuntimeContext( @@ -128,7 +133,12 @@ public class ScriptExecutionActor : ReceiveActor siteId, // Notification Outbox (FU3): stamp the executing script onto outbound // notifications using the Site Event Logging "Source" convention. - sourceScript: $"ScriptActor:{scriptName}"); + sourceScript: $"ScriptActor:{scriptName}", + // Audit Log #23 (M2 Bundle F): emit one ApiOutbound/ApiCall row per + // ExternalSystem.Call. Writer is best-effort; failures are logged + // and swallowed inside the helper so the script's call path is + // never aborted by an audit failure. + auditWriter: auditWriter); var globals = new ScriptGlobals { diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs index ba00063..2ac8a38 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -1,6 +1,9 @@ +using System.Diagnostics; using System.Text.Json; +using System.Text.RegularExpressions; using Akka.Actor; using Microsoft.Extensions.Logging; +using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.Instance; using ScadaLink.Commons.Messages.Notification; @@ -75,6 +78,13 @@ public class ScriptRuntimeContext /// private readonly string? _sourceScript; + /// + /// Audit Log #23: best-effort emitter for boundary-crossing actions executed + /// by the script. Optional — when null the helpers degrade to a no-op audit + /// path so tests / contexts that do not need the audit pipeline still work. + /// + private readonly IAuditWriter? _auditWriter; + public ScriptRuntimeContext( IActorRef instanceActor, IActorRef self, @@ -89,7 +99,8 @@ public class ScriptRuntimeContext StoreAndForwardService? storeAndForward = null, ICanTell? siteCommunicationActor = null, string siteId = "", - string? sourceScript = null) + string? sourceScript = null, + IAuditWriter? auditWriter = null) { _instanceActor = instanceActor; _self = self; @@ -105,6 +116,7 @@ public class ScriptRuntimeContext _siteCommunicationActor = siteCommunicationActor; _siteId = siteId; _sourceScript = sourceScript; + _auditWriter = auditWriter; } /// @@ -204,7 +216,8 @@ public class ScriptRuntimeContext /// ExternalSystem.Call("systemName", "methodName", params) /// ExternalSystem.CachedCall("systemName", "methodName", params) /// - public ExternalSystemHelper ExternalSystem => new(_externalSystemClient, _instanceName, _logger); + public ExternalSystemHelper ExternalSystem => new( + _externalSystemClient, _instanceName, _logger, _auditWriter, _siteId, _sourceScript); /// /// WP-13: Provides access to database operations. @@ -275,17 +288,41 @@ public class ScriptRuntimeContext /// /// WP-13: Helper for ExternalSystem.Call/CachedCall syntax. /// + /// + /// Audit Log #23 (M2 Bundle F): every invocation emits + /// one ApiOutbound/ApiCall audit row via . + /// The audit emission is wrapped in a try/catch that swallows every exception + /// — the audit pipeline is best-effort and must NEVER abort the script's + /// outbound call (alog.md §7). The original + /// (or the original thrown exception) flows back to the caller unchanged. + /// public class ExternalSystemHelper { + private static readonly Regex HttpStatusRegex = new( + @"HTTP\s+(?\d{3})", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private readonly IExternalSystemClient? _client; private readonly string _instanceName; private readonly ILogger _logger; + private readonly IAuditWriter? _auditWriter; + private readonly string _siteId; + private readonly string? _sourceScript; - internal ExternalSystemHelper(IExternalSystemClient? client, string instanceName, ILogger logger) + internal ExternalSystemHelper( + IExternalSystemClient? client, + string instanceName, + ILogger logger, + IAuditWriter? auditWriter = null, + string siteId = "", + string? sourceScript = null) { _client = client; _instanceName = instanceName; _logger = logger; + _auditWriter = auditWriter; + _siteId = siteId; + _sourceScript = sourceScript; } public async Task Call( @@ -297,7 +334,31 @@ public class ScriptRuntimeContext if (_client == null) throw new InvalidOperationException("External system client not available"); - return await _client.CallAsync(systemName, methodName, parameters, cancellationToken); + // Audit Log #23 (M2 Bundle F): wrap the outbound call so every + // attempt emits exactly one ApiOutbound/ApiCall row. The wrapper + // mirrors the existing call-site behaviour — the original result + // OR original exception flows back to the script untouched; the + // audit emission is best-effort. + var occurredAtUtc = DateTime.UtcNow; + var startTicks = Stopwatch.GetTimestamp(); + ExternalCallResult? result = null; + Exception? thrown = null; + try + { + result = await _client.CallAsync(systemName, methodName, parameters, cancellationToken); + return result; + } + catch (Exception ex) + { + thrown = ex; + throw; + } + finally + { + var elapsedMs = (int)((Stopwatch.GetTimestamp() - startTicks) + * 1000d / Stopwatch.Frequency); + EmitCallAudit(systemName, methodName, occurredAtUtc, elapsedMs, result, thrown); + } } public async Task CachedCall( @@ -311,6 +372,145 @@ public class ScriptRuntimeContext return await _client.CachedCallAsync(systemName, methodName, parameters, _instanceName, cancellationToken); } + + /// + /// Best-effort emission of one ApiOutbound/ApiCall audit + /// row. Any exception thrown by the writer is logged and swallowed — + /// audit-write failures must never abort the user-facing action. + /// + private void EmitCallAudit( + string systemName, + string methodName, + DateTime occurredAtUtc, + int durationMs, + ExternalCallResult? result, + Exception? thrown) + { + if (_auditWriter == null) + { + return; + } + + AuditEvent evt; + try + { + evt = BuildCallAuditEvent(systemName, methodName, occurredAtUtc, durationMs, result, thrown); + } + catch (Exception buildEx) + { + // Building the event itself must never propagate. This is a + // defensive guard — populating a record from already-validated + // values shouldn't throw, but we honour the alog.md §7 + // best-effort contract regardless. + _logger.LogWarning(buildEx, + "Failed to build Audit Log #23 event for {System}.{Method} — skipping emission", + systemName, methodName); + return; + } + + try + { + // Fire-and-forget so we never block the script on the audit + // writer; the writer itself is responsible for fast, durable + // enqueue (site SQLite hot-path). We DO 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} ({System}.{Method})", + evt.EventId, systemName, methodName), + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + else if (writeTask.IsFaulted) + { + _logger.LogWarning(writeTask.Exception, + "Audit Log #23 write failed for EventId {EventId} ({System}.{Method})", + evt.EventId, systemName, methodName); + } + } + catch (Exception writeEx) + { + // Synchronous throw from WriteAsync (e.g. ArgumentNullException + // before the writer's own try/catch). Swallow + log per the + // alog.md §7 contract. + _logger.LogWarning(writeEx, + "Audit Log #23 write threw synchronously for EventId {EventId} ({System}.{Method})", + evt.EventId, systemName, methodName); + } + } + + private AuditEvent BuildCallAuditEvent( + string systemName, + string methodName, + DateTime occurredAtUtc, + int durationMs, + ExternalCallResult? result, + Exception? thrown) + { + // Status: Delivered on a Success result; Failed otherwise (the + // ExternalSystemClient already maps HTTP non-2xx + transient + // exceptions into Success=false on the result, or surfaces a raw + // exception). M2 makes no distinction between transient + permanent + // failure here — both manifest as Status.Failed on the sync path. + var status = (thrown == null && result != null && result.Success) + ? AuditStatus.Delivered + : AuditStatus.Failed; + + string? errorMessage = null; + string? errorDetail = null; + int? httpStatus = null; + + if (thrown != null) + { + errorMessage = thrown.Message; + errorDetail = thrown.ToString(); + } + else if (result != null && !result.Success) + { + errorMessage = result.ErrorMessage; + // The ExternalSystemClient embeds the HTTP status code in the + // error message as "HTTP {code}". Parse it back out so the + // audit row carries the structured value. + if (!string.IsNullOrEmpty(result.ErrorMessage)) + { + var match = HttpStatusRegex.Match(result.ErrorMessage); + if (match.Success + && int.TryParse(match.Groups["code"].Value, out var parsed)) + { + httpStatus = parsed; + } + } + } + + return new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + CorrelationId = null, + SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, + SourceInstanceId = _instanceName, + SourceScript = _sourceScript, + Actor = null, + Target = $"{systemName}.{methodName}", + Status = status, + HttpStatus = httpStatus, + DurationMs = durationMs, + ErrorMessage = errorMessage, + ErrorDetail = errorDetail, + RequestSummary = null, + ResponseSummary = null, + PayloadTruncated = false, + Extra = null, + ForwardState = AuditForwardState.Pending, + }; + } } /// diff --git a/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs b/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs index a1057a1..03d337a 100644 --- a/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs @@ -1,28 +1,43 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ScadaLink.AuditLog.Configuration; +using ScadaLink.AuditLog.Site; +using ScadaLink.AuditLog.Site.Telemetry; +using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.HealthMonitoring; namespace ScadaLink.AuditLog.Tests; /// -/// Bundle E (M1) smoke tests for the Audit Log (#23) DI scaffold. Verifies -/// AddAuditLog registers against the -/// AuditLog configuration section. Bundle E ships only the scaffold; -/// the validator + full options surface land in Task 9. +/// Bundle E (M2 Task E1) DI surface tests for AddAuditLog. M1 shipped +/// the options-only scaffold; M2 extends it with the site writer chain +/// ( + + +/// ) and the telemetry collaborators +/// (, , +/// ). /// public class AddAuditLogTests { - [Fact] - public void AddAuditLog_RegistersAuditLogOptions() + private static ServiceProvider BuildProvider(IDictionary? settings = null) { var config = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary()) + .AddInMemoryCollection(settings ?? new Dictionary()) .Build(); var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); services.AddAuditLog(config); - var provider = services.BuildServiceProvider(); + return services.BuildServiceProvider(); + } + + [Fact] + public void AddAuditLog_RegistersAuditLogOptions() + { + using var provider = BuildProvider(); var opts = provider.GetService>(); @@ -47,4 +62,182 @@ public class AddAuditLogTests Assert.Throws( () => services.AddAuditLog(null!)); } + + // -- Bundle E (M2 Task E1) --------------------------------------------- + + [Fact] + public void AddAuditLog_Registers_SqliteAuditWriter_Singleton_FromDI() + { + using var provider = BuildProvider(new Dictionary + { + // In-memory database keeps the writer's owned connection portable + // across tests; the per-instance Cache=Shared in the writer's + // default connection string ensures no on-disk file is touched. + ["AuditLog:SiteWriter:DatabasePath"] = ":memory:", + }); + + var writer = provider.GetService(); + + Assert.NotNull(writer); + // Singleton — same instance on a second resolve. + Assert.Same(writer, provider.GetService()); + } + + [Fact] + public void AddAuditLog_Registers_IAuditWriter_AsFallbackAuditWriter() + { + using var provider = BuildProvider(new Dictionary + { + ["AuditLog:SiteWriter:DatabasePath"] = ":memory:", + }); + + var writer = provider.GetService(); + + Assert.NotNull(writer); + Assert.IsType(writer); + } + + [Fact] + public void AddAuditLog_Registers_ISiteAuditQueue_AsSameInstance_As_SqliteAuditWriter() + { + // The telemetry actor reads from ISiteAuditQueue while scripts write + // through IAuditWriter → SqliteAuditWriter. Both surfaces MUST resolve + // to the same instance or pending rows will never be visible. + using var provider = BuildProvider(new Dictionary + { + ["AuditLog:SiteWriter:DatabasePath"] = ":memory:", + }); + + var queue = provider.GetService(); + var writer = provider.GetService(); + + Assert.NotNull(queue); + Assert.NotNull(writer); + Assert.Same(writer, queue); + } + + [Fact] + public void AddAuditLog_Registers_RingBufferFallback_Singleton() + { + using var provider = BuildProvider(new Dictionary + { + ["AuditLog:SiteWriter:DatabasePath"] = ":memory:", + }); + + var ring = provider.GetService(); + Assert.NotNull(ring); + Assert.Same(ring, provider.GetService()); + } + + [Fact] + public void AddAuditLog_Registers_AuditWriteFailureCounter_AsNoOpDefault() + { + using var provider = BuildProvider(new Dictionary + { + ["AuditLog:SiteWriter:DatabasePath"] = ":memory:", + }); + + var counter = provider.GetService(); + Assert.NotNull(counter); + Assert.IsType(counter); + } + + [Fact] + public void AddAuditLog_Registers_SiteStreamAuditClient_AsNoOpDefault() + { + using var provider = BuildProvider(new Dictionary + { + ["AuditLog:SiteWriter:DatabasePath"] = ":memory:", + }); + + var client = provider.GetService(); + Assert.NotNull(client); + Assert.IsType(client); + } + + [Fact] + public void AddAuditLog_Options_Bind_RoundTrip_SqliteWriter() + { + using var provider = BuildProvider(new Dictionary + { + ["AuditLog:SiteWriter:DatabasePath"] = "/tmp/test-audit.db", + ["AuditLog:SiteWriter:ChannelCapacity"] = "8192", + ["AuditLog:SiteWriter:BatchSize"] = "128", + ["AuditLog:SiteWriter:FlushIntervalMs"] = "75", + }); + + var opts = provider.GetRequiredService>().Value; + Assert.Equal("/tmp/test-audit.db", opts.DatabasePath); + Assert.Equal(8192, opts.ChannelCapacity); + Assert.Equal(128, opts.BatchSize); + Assert.Equal(75, opts.FlushIntervalMs); + } + + [Fact] + public void AddAuditLog_Options_Bind_RoundTrip_SiteTelemetry() + { + using var provider = BuildProvider(new Dictionary + { + ["AuditLog:SiteTelemetry:BatchSize"] = "512", + ["AuditLog:SiteTelemetry:BusyIntervalSeconds"] = "3", + ["AuditLog:SiteTelemetry:IdleIntervalSeconds"] = "60", + }); + + var opts = provider.GetRequiredService>().Value; + Assert.Equal(512, opts.BatchSize); + Assert.Equal(3, opts.BusyIntervalSeconds); + Assert.Equal(60, opts.IdleIntervalSeconds); + } + + // -- Bundle G (M2 Task G1) Site Health Monitoring bridge ---------------- + + [Fact] + public void AddAuditLogHealthMetricsBridge_Swaps_FailureCounter_To_HealthMetrics_Implementation() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AuditLog:SiteWriter:DatabasePath"] = ":memory:", + }) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddAuditLog(config); + // The bridge depends on ISiteHealthCollector; AddHealthMonitoring is + // what registers it on the site (and the central self-host). + services.AddHealthMonitoring(); + services.AddAuditLogHealthMetricsBridge(); + using var provider = services.BuildServiceProvider(); + + var counter = provider.GetRequiredService(); + + Assert.IsType(counter); + } + + [Fact] + public void AddAuditLogHealthMetricsBridge_Without_HealthMonitoring_Still_Resolves_But_Errors_On_Use() + { + // The bridge replaces the registration unconditionally; resolving the + // counter when ISiteHealthCollector is missing throws at GetRequiredService + // time. This documents the contract — callers must register + // AddHealthMonitoring() before the bridge. + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AuditLog:SiteWriter:DatabasePath"] = ":memory:", + }) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddAuditLog(config); + services.AddAuditLogHealthMetricsBridge(); + using var provider = services.BuildServiceProvider(); + + Assert.Throws( + () => provider.GetRequiredService()); + } } diff --git a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs new file mode 100644 index 0000000..36de05f --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs @@ -0,0 +1,220 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using ScadaLink.AuditLog.Central; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Messages.Audit; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.ConfigurationDatabase; +using ScadaLink.ConfigurationDatabase.Repositories; +using ScadaLink.ConfigurationDatabase.Tests.Migrations; + +namespace ScadaLink.AuditLog.Tests.Central; + +/// +/// Bundle D D2 tests for . Uses the same +/// as the M1 repository tests so the actor +/// exercises real +/// against a partitioned MSSQL schema (the only way to verify the +/// IngestedAtUtc stamp + duplicate-key idempotency end to end). +/// +public class AuditLogIngestActorTests : TestKit, IClassFixture +{ + private readonly MsSqlMigrationFixture _fixture; + + public AuditLogIngestActorTests(MsSqlMigrationFixture fixture) + { + _fixture = fixture; + } + + private ScadaLinkDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(_fixture.ConnectionString) + .Options; + return new ScadaLinkDbContext(options); + } + + private static string NewSiteId() => + "test-bundle-d2-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + private static AuditEvent NewEvent(string siteId, Guid? id = null) => new() + { + EventId = id ?? Guid.NewGuid(), + OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Delivered, + SourceSiteId = siteId, + }; + + private IActorRef CreateActor(IAuditLogRepository repository) => + Sys.ActorOf(Props.Create(() => new AuditLogIngestActor( + repository, + NullLogger.Instance))); + + [SkippableFact] + public async Task Receive_BatchOf5_Calls_Repo_5Times_Acks_All_5() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + var events = Enumerable.Range(0, 5).Select(_ => NewEvent(siteId)).ToList(); + + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + var actor = CreateActor(repo); + + actor.Tell(new IngestAuditEventsCommand(events), TestActor); + + var reply = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.Equal(5, reply.AcceptedEventIds.Count); + Assert.True(events.Select(e => e.EventId).ToHashSet().SetEquals(reply.AcceptedEventIds.ToHashSet())); + + // Verify rows landed in MSSQL. + await using var readContext = CreateContext(); + var rows = await readContext.Set() + .Where(e => e.SourceSiteId == siteId) + .ToListAsync(); + Assert.Equal(5, rows.Count); + } + + [SkippableFact] + public async Task Receive_BatchWith_AlreadyExistingEvent_AcksAll_NoDoubleInsert() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + var pre = NewEvent(siteId); + + // Pre-insert one event directly via the repo so the actor sees it + // already present when it processes the batch. + await using (var seedContext = CreateContext()) + { + var seedRepo = new AuditLogRepository(seedContext); + await seedRepo.InsertIfNotExistsAsync(pre); + } + + // Build the batch including the pre-existing event plus 2 new ones. + var fresh1 = NewEvent(siteId); + var fresh2 = NewEvent(siteId); + var batch = new List { pre, fresh1, fresh2 }; + + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + var actor = CreateActor(repo); + + actor.Tell(new IngestAuditEventsCommand(batch), TestActor); + + var reply = ExpectMsg(TimeSpan.FromSeconds(10)); + // All 3 acked under idempotent first-write-wins. + Assert.Equal(3, reply.AcceptedEventIds.Count); + + // Verify no double-insert. + await using var readContext = CreateContext(); + var count = await readContext.Set() + .Where(e => e.SourceSiteId == siteId) + .CountAsync(); + Assert.Equal(3, count); + } + + [SkippableFact] + public async Task Receive_Sets_IngestedAtUtc_Before_Insert() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + var events = Enumerable.Range(0, 3).Select(_ => NewEvent(siteId)).ToList(); + + var before = DateTime.UtcNow.AddSeconds(-1); + + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + var actor = CreateActor(repo); + + actor.Tell(new IngestAuditEventsCommand(events), TestActor); + ExpectMsg(TimeSpan.FromSeconds(10)); + + var after = DateTime.UtcNow.AddSeconds(1); + + await using var readContext = CreateContext(); + var rows = await readContext.Set() + .Where(e => e.SourceSiteId == siteId) + .ToListAsync(); + + Assert.Equal(3, rows.Count); + Assert.All(rows, r => + { + Assert.NotNull(r.IngestedAtUtc); + Assert.InRange(r.IngestedAtUtc!.Value, before, after); + }); + } + + [SkippableFact] + public async Task Receive_RepoThrowsForOneEvent_Other4StillPersisted() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + var events = Enumerable.Range(0, 5).Select(_ => NewEvent(siteId)).ToList(); + var poisonId = events[2].EventId; + + // Wrapper repo that throws only when the poison EventId is being + // inserted. The four neighbours must still land in MSSQL. + await using var context = CreateContext(); + var realRepo = new AuditLogRepository(context); + var wrappedRepo = new ThrowingRepository(realRepo, poisonId); + var actor = CreateActor(wrappedRepo); + + actor.Tell(new IngestAuditEventsCommand(events), TestActor); + var reply = ExpectMsg(TimeSpan.FromSeconds(10)); + + // The actor catches the throw per-row, so 4 ids are accepted and 1 is + // left out. + Assert.Equal(4, reply.AcceptedEventIds.Count); + Assert.DoesNotContain(poisonId, reply.AcceptedEventIds); + + await using var readContext = CreateContext(); + var rows = await readContext.Set() + .Where(e => e.SourceSiteId == siteId) + .ToListAsync(); + Assert.Equal(4, rows.Count); + Assert.DoesNotContain(rows, r => r.EventId == poisonId); + } + + /// + /// Tiny test double that delegates to a real repository but throws on a + /// specified EventId. Used to verify per-row failure isolation: one bad + /// row must not cause the rest of the batch to be lost. + /// + private sealed class ThrowingRepository : IAuditLogRepository + { + private readonly IAuditLogRepository _inner; + private readonly Guid _poisonId; + + public ThrowingRepository(IAuditLogRepository inner, Guid poisonId) + { + _inner = inner; + _poisonId = poisonId; + } + + public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) + { + if (evt.EventId == _poisonId) + { + throw new InvalidOperationException("simulated repo failure for poison row"); + } + return _inner.InsertIfNotExistsAsync(evt, ct); + } + + public Task> QueryAsync( + AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) => + _inner.QueryAsync(filter, paging, ct); + + public Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default) => + _inner.SwitchOutPartitionAsync(monthBoundary, ct); + } +} diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs new file mode 100644 index 0000000..5f16ce9 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs @@ -0,0 +1,341 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ScadaLink.AuditLog.Central; +using ScadaLink.AuditLog.Site; +using ScadaLink.AuditLog.Site.Telemetry; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Messages.Audit; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.ConfigurationDatabase; +using ScadaLink.ConfigurationDatabase.Repositories; +using ScadaLink.ConfigurationDatabase.Tests.Migrations; +using ScadaLink.Communication.Grpc; + +namespace ScadaLink.AuditLog.Tests.Integration; + +/// +/// Bundle H — end-to-end test wiring the full Audit Log #23 M2 sync-call pipeline: +/// over a backed by +/// an in-memory SQLite database; the drains +/// Pending rows and pushes them through a stub +/// that forwards directly to the central backed +/// by a real on the . +/// +/// +/// +/// This is a component-level integration test, not a full Akka-cluster +/// test (per the M2 brainstorm decision). The stub gRPC client short-circuits +/// the wire so we exercise the real telemetry actor, the real ingest actor, the +/// real SQLite writer, and the real MSSQL repository — without standing up a +/// Kestrel host or two-cluster topology. +/// +/// +/// The site-side telemetry actor's Drain message is private; rather than +/// expose it we drive the drain by setting BusyIntervalSeconds = 1 so the +/// initial scheduled tick fires within a second of actor start. Tests then +/// until the central repository +/// observes the expected rows. +/// +/// +/// Each test uses a unique SourceSiteId (Guid suffix) so concurrent tests +/// and the per-fixture MSSQL database lifetime don't interfere with each other. +/// +/// +public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture +{ + private readonly MsSqlMigrationFixture _fixture; + + public SyncCallEmissionEndToEndTests(MsSqlMigrationFixture fixture) + { + _fixture = fixture; + } + + private static string NewSiteId() => + "test-bundle-h-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + private ScadaLinkDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(_fixture.ConnectionString) + .Options; + return new ScadaLinkDbContext(options); + } + + private static AuditEvent NewEvent(string siteId, Guid? id = null) => new() + { + EventId = id ?? Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Delivered, + SourceSiteId = siteId, + Target = "external-system-a/method", + }; + + private static IOptions InMemorySqliteOptions() => + Options.Create(new SqliteAuditWriterOptions + { + // Per-test unique database name + Mode=Memory + Cache=Shared keeps + // the in-memory database alive for the duration of the test even + // though Microsoft.Data.Sqlite tears the file down with the last + // connection. The DatabasePath field is unused because we override + // the connection string below. + DatabasePath = "ignored", + BatchSize = 64, + ChannelCapacity = 1024, + }); + + private static SqliteAuditWriter CreateInMemorySqliteWriter() => + // The 3rd constructor argument is connectionStringOverride. A unique + // shared-cache in-memory URI keeps the schema scoped to this writer + // instance and torn down when the writer is disposed. + new SqliteAuditWriter( + InMemorySqliteOptions(), + NullLogger.Instance, + connectionStringOverride: $"Data Source=file:auditlog-h-{Guid.NewGuid():N}?mode=memory&cache=shared"); + + private static IOptions FastTelemetryOptions() => + Options.Create(new SiteAuditTelemetryOptions + { + BatchSize = 256, + // 1s for both intervals so the initial scheduled tick fires fast + // and any failure-driven re-tick also fires fast — without + // requiring a public Drain message to be exposed. + BusyIntervalSeconds = 1, + IdleIntervalSeconds = 1, + }); + + private IActorRef CreateIngestActor(IAuditLogRepository repo) => + Sys.ActorOf(Props.Create(() => new AuditLogIngestActor( + repo, + NullLogger.Instance))); + + private IActorRef CreateTelemetryActor( + ISiteAuditQueue queue, + ISiteStreamAuditClient client) => + Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor( + queue, + client, + FastTelemetryOptions(), + NullLogger.Instance))); + + [SkippableFact] + public async Task EndToEnd_OneWrittenEvent_Reaches_Central_AuditLog_Within_Reasonable_Time() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + + // Real central wiring: repo + ingest actor. + await using var ingestContext = CreateContext(); + var ingestRepo = new AuditLogRepository(ingestContext); + var ingestActor = CreateIngestActor(ingestRepo); + + // Real site wiring: SQLite (in-memory) + ring + fallback + telemetry. + await using var sqliteWriter = CreateInMemorySqliteWriter(); + var ring = new RingBufferFallback(); + var fallback = new FallbackAuditWriter( + sqliteWriter, + ring, + new NoOpAuditWriteFailureCounter(), + NullLogger.Instance); + + var stubClient = new DirectActorSiteStreamAuditClient(ingestActor); + CreateTelemetryActor(sqliteWriter, stubClient); + + // Act — one fresh event written via the FallbackAuditWriter hot-path. + var evt = NewEvent(siteId); + await fallback.WriteAsync(evt); + + // Assert — the central AuditLog row materialises within a window that + // covers initial tick (1s) + a generous slack for SQLite + the 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)); + Assert.Single(rows); + Assert.Equal(evt.EventId, rows[0].EventId); + // Central stamps IngestedAtUtc; site never sets it. + Assert.NotNull(rows[0].IngestedAtUtc); + }, TimeSpan.FromSeconds(15)); + } + + [SkippableFact] + public async Task EndToEnd_GrpcStubError_RowStays_Pending_NextTick_Succeeds() + { + 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.Instance); + + // Stub fails the first push; subsequent calls flow through. The + // telemetry actor's on-failure branch keeps rows in Pending state, so + // the next tick re-reads them and tries again. + var stubClient = new DirectActorSiteStreamAuditClient(ingestActor) + { + FailNextCallCount = 1, + }; + CreateTelemetryActor(sqliteWriter, stubClient); + + var evt = NewEvent(siteId); + await fallback.WriteAsync(evt); + + // Wait long enough for at least one failure-then-success cycle. With + // both intervals = 1s the actor retries quickly; allow 15s for slow CI. + 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)); + Assert.Single(rows); + Assert.Equal(evt.EventId, rows[0].EventId); + }, TimeSpan.FromSeconds(15)); + + Assert.True(stubClient.CallCount >= 2, + $"Expected at least one failed push + one successful push; saw {stubClient.CallCount} total client calls."); + + // The site SQLite row must have flipped to Forwarded after the + // successful retry. ReadPendingAsync only returns Pending rows; the + // row should NOT show up there anymore. + var stillPending = await sqliteWriter.ReadPendingAsync(64); + Assert.DoesNotContain(stillPending, p => p.EventId == evt.EventId); + } + + [SkippableFact] + public async Task EndToEnd_DuplicateSubmit_OnlyOneCentralRow() + { + 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.Instance); + + var stubClient = new DirectActorSiteStreamAuditClient(ingestActor); + CreateTelemetryActor(sqliteWriter, stubClient); + + // Both writes carry the SAME EventId. Site SQLite's PRIMARY KEY + // constraint and the central repo's InsertIfNotExistsAsync both + // enforce first-write-wins, so only one central row must materialise. + var sharedId = Guid.NewGuid(); + var evt1 = NewEvent(siteId, sharedId); + var evt2 = NewEvent(siteId, sharedId); + + await fallback.WriteAsync(evt1); + await fallback.WriteAsync(evt2); + + 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)); + Assert.Single(rows); + Assert.Equal(sharedId, rows[0].EventId); + }, TimeSpan.FromSeconds(15)); + } + + /// + /// Test double for that short-circuits + /// the gRPC wire and forwards the batch directly to a central + /// via Akka . The + /// Akka is converted to the proto + /// that the telemetry actor expects. + /// + private sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient + { + private readonly IActorRef _ingestActor; + private int _failsRemaining; + private int _callCount; + + public DirectActorSiteStreamAuditClient(IActorRef ingestActor) + { + _ingestActor = ingestActor ?? throw new ArgumentNullException(nameof(ingestActor)); + } + + /// + /// When > 0, the next FailNextCallCount invocations of + /// throw to simulate a gRPC error; + /// after that count is exhausted, calls succeed normally. + /// + public int FailNextCallCount + { + get => _failsRemaining; + set => _failsRemaining = value; + } + + public int CallCount => Volatile.Read(ref _callCount); + + public async Task IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct) + { + Interlocked.Increment(ref _callCount); + + // Atomically consume one of the queued failures, if any. This + // lets the test arm a deterministic number of failures before the + // stub recovers. + if (Interlocked.Decrement(ref _failsRemaining) >= 0) + { + throw new InvalidOperationException("simulated gRPC failure for test"); + } + + // Decrement under-ran into negative territory; clamp at -1 to keep + // the field bounded even under many calls. + Interlocked.Exchange(ref _failsRemaining, -1); + + // Decode the proto batch back into AuditEvent records — this + // mirrors what the production SiteStreamGrpcServer does before + // dispatching to the ingest actor (see Bundle D's gRPC handler). + var events = new List(batch.Events.Count); + foreach (var dto in batch.Events) + { + events.Add(ScadaLink.AuditLog.Telemetry.AuditEventMapper.FromDto(dto)); + } + + // Ask the central actor; the reply carries the accepted EventIds. + var reply = await _ingestActor + .Ask( + new IngestAuditEventsCommand(events), + TimeSpan.FromSeconds(10)) + .ConfigureAwait(false); + + var ack = new IngestAck(); + foreach (var id in reply.AcceptedEventIds) + { + ack.AcceptedEventIds.Add(id.ToString()); + } + return ack; + } + } +} diff --git a/tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj b/tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj index 9a866be..625f9c6 100644 --- a/tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj +++ b/tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj @@ -9,11 +9,30 @@ + + + + + + + + + + @@ -22,6 +41,13 @@ + + diff --git a/tests/ScadaLink.AuditLog.Tests/Site/FallbackAuditWriterTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/FallbackAuditWriterTests.cs new file mode 100644 index 0000000..bb39c24 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Site/FallbackAuditWriterTests.cs @@ -0,0 +1,133 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using ScadaLink.AuditLog.Site; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.AuditLog.Tests.Site; + +/// +/// Bundle B (M2-T4) tests for — composes the +/// primary , the drop-oldest +/// , and an +/// health counter. +/// +public class FallbackAuditWriterTests +{ + private static AuditEvent NewEvent(string? target = null) => new() + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Delivered, + Target = target, + PayloadTruncated = false, + ForwardState = AuditForwardState.Pending, + }; + + /// Flip-switch primary writer mock. + private sealed class FlipSwitchPrimary : IAuditWriter + { + public bool FailNext { get; set; } + public List Written { get; } = new(); + + public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) + { + if (FailNext) + { + return Task.FromException(new InvalidOperationException("primary down")); + } + Written.Add(evt); + return Task.CompletedTask; + } + } + + [Fact] + public async Task WriteAsync_PrimaryThrows_EventLandsInRing_CallReturnsSuccess() + { + var primary = new FlipSwitchPrimary { FailNext = true }; + var ring = new RingBufferFallback(capacity: 16); + var counter = Substitute.For(); + + var fallback = new FallbackAuditWriter(primary, ring, counter, NullLogger.Instance); + + var evt = NewEvent("doomed"); + // Must NOT throw — audit failures are always swallowed at this layer. + await fallback.WriteAsync(evt); + + Assert.Equal(1, ring.Count); + counter.Received(1).Increment(); + } + + [Fact] + public async Task WriteAsync_PrimaryRecovers_RingDrains_InFIFOOrder_OnNextWrite() + { + var primary = new FlipSwitchPrimary { FailNext = true }; + var ring = new RingBufferFallback(capacity: 16); + var counter = Substitute.For(); + + var fallback = new FallbackAuditWriter(primary, ring, counter, NullLogger.Instance); + + var failed = new[] { NewEvent("a"), NewEvent("b"), NewEvent("c") }; + foreach (var e in failed) + { + await fallback.WriteAsync(e); + } + + Assert.Equal(3, ring.Count); + + // Primary recovers; the very next successful write should drain the + // ring in FIFO order through the primary. + primary.FailNext = false; + var trigger = NewEvent("trigger"); + await fallback.WriteAsync(trigger); + + Assert.Equal(0, ring.Count); + // Order: the triggering event reaches the primary first (that's the + // signal the primary has recovered), then the backlog drains in FIFO + // submission order behind it. + Assert.Equal(4, primary.Written.Count); + Assert.Equal("trigger", primary.Written[0].Target); + Assert.Equal("a", primary.Written[1].Target); + Assert.Equal("b", primary.Written[2].Target); + Assert.Equal("c", primary.Written[3].Target); + } + + [Fact] + public async Task WriteAsync_PrimaryAlwaysSucceeds_Ring_StaysEmpty() + { + var primary = new FlipSwitchPrimary(); + var ring = new RingBufferFallback(capacity: 16); + var counter = Substitute.For(); + + var fallback = new FallbackAuditWriter(primary, ring, counter, NullLogger.Instance); + + for (int i = 0; i < 10; i++) + { + await fallback.WriteAsync(NewEvent()); + } + + Assert.Equal(0, ring.Count); + Assert.Equal(10, primary.Written.Count); + counter.DidNotReceive().Increment(); + } + + [Fact] + public async Task WriteAsync_FailureCounter_Incremented_Per_PrimaryFailure() + { + var primary = new FlipSwitchPrimary { FailNext = true }; + var ring = new RingBufferFallback(capacity: 16); + var counter = Substitute.For(); + + var fallback = new FallbackAuditWriter(primary, ring, counter, NullLogger.Instance); + + for (int i = 0; i < 5; i++) + { + await fallback.WriteAsync(NewEvent()); + } + + counter.Received(5).Increment(); + } +} diff --git a/tests/ScadaLink.AuditLog.Tests/Site/HealthMetricsAuditWriteFailureCounterTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/HealthMetricsAuditWriteFailureCounterTests.cs new file mode 100644 index 0000000..cb8d9d2 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Site/HealthMetricsAuditWriteFailureCounterTests.cs @@ -0,0 +1,46 @@ +using NSubstitute; +using ScadaLink.AuditLog.Site; +using ScadaLink.HealthMonitoring; + +namespace ScadaLink.AuditLog.Tests.Site; + +/// +/// Bundle G (M2-T11) — the +/// adapter is the production binding for +/// on site nodes; it forwards every FallbackAuditWriter primary failure into +/// the shared so the site health report +/// surfaces the failure count as SiteAuditWriteFailures. +/// +public class HealthMetricsAuditWriteFailureCounterTests +{ + [Fact] + public void Increment_Routes_To_Collector_IncrementSiteAuditWriteFailures() + { + var collector = Substitute.For(); + var counter = new HealthMetricsAuditWriteFailureCounter(collector); + + counter.Increment(); + + collector.Received(1).IncrementSiteAuditWriteFailures(); + } + + [Fact] + public void Increment_Multiple_Calls_Route_To_Collector_Each_Time() + { + var collector = Substitute.For(); + var counter = new HealthMetricsAuditWriteFailureCounter(collector); + + counter.Increment(); + counter.Increment(); + counter.Increment(); + + collector.Received(3).IncrementSiteAuditWriteFailures(); + } + + [Fact] + public void Construction_With_Null_Collector_Throws_ArgumentNullException() + { + Assert.Throws( + () => new HealthMetricsAuditWriteFailureCounter(null!)); + } +} diff --git a/tests/ScadaLink.AuditLog.Tests/Site/RingBufferFallbackTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/RingBufferFallbackTests.cs new file mode 100644 index 0000000..8f92802 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Site/RingBufferFallbackTests.cs @@ -0,0 +1,91 @@ +using ScadaLink.AuditLog.Site; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.AuditLog.Tests.Site; + +/// +/// Bundle B (M2-T3) tests for — the +/// drop-oldest fallback used by when the +/// primary SQLite writer is throwing. +/// +public class RingBufferFallbackTests +{ + private static AuditEvent NewEvent(string? target = null) + { + return new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Delivered, + Target = target, + PayloadTruncated = false, + ForwardState = AuditForwardState.Pending, + }; + } + + [Fact] + public async Task Enqueue_1025_Into_1024Cap_Ring_DropsOldest_AndRaisesOverflowOnce() + { + var ring = new RingBufferFallback(capacity: 1024); + var overflowCount = 0; + ring.RingBufferOverflowed += () => Interlocked.Increment(ref overflowCount); + + var events = Enumerable.Range(0, 1025).Select(i => NewEvent(target: i.ToString())).ToList(); + foreach (var e in events) + { + Assert.True(ring.TryEnqueue(e)); + } + + Assert.Equal(1, overflowCount); + + // The surviving 1024 are events[1..1024] (oldest dropped). + var drained = new List(); + ring.Complete(); + await foreach (var e in ring.DrainAsync(CancellationToken.None)) + { + drained.Add(e); + } + + Assert.Equal(1024, drained.Count); + Assert.Equal("1", drained[0].Target); + Assert.Equal("1024", drained[^1].Target); + } + + [Fact] + public async Task DrainAsync_Yields_FIFO_Then_Completes_When_Empty() + { + var ring = new RingBufferFallback(capacity: 16); + var enqueued = Enumerable.Range(0, 5).Select(i => NewEvent(target: i.ToString())).ToList(); + foreach (var e in enqueued) + { + Assert.True(ring.TryEnqueue(e)); + } + + ring.Complete(); + + var drained = new List(); + await foreach (var e in ring.DrainAsync(CancellationToken.None)) + { + drained.Add(e); + } + + Assert.Equal(5, drained.Count); + for (int i = 0; i < 5; i++) + { + Assert.Equal(i.ToString(), drained[i].Target); + } + } + + [Fact] + public void TryEnqueue_AllSucceeds_ReturnsTrue() + { + var ring = new RingBufferFallback(capacity: 16); + for (int i = 0; i < 8; i++) + { + Assert.True(ring.TryEnqueue(NewEvent())); + } + } +} diff --git a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs new file mode 100644 index 0000000..b02dd63 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs @@ -0,0 +1,128 @@ +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ScadaLink.AuditLog.Site; + +namespace ScadaLink.AuditLog.Tests.Site; + +/// +/// Bundle B (M2-T1) schema-bootstrap tests for . +/// Uses an in-memory shared-cache SQLite database so the same connection name +/// reaches the same file-less db across both the writer and the verifier. +/// +public class SqliteAuditWriterSchemaTests +{ + /// + /// Each test uses a unique shared-cache in-memory database. The + /// "Mode=Memory;Cache=Shared" syntax lets two SqliteConnections see the same + /// in-memory store as long as both use the same Data Source name. + /// + private static (SqliteAuditWriter writer, string dataSource) CreateWriter(string testName) + { + var dataSource = $"file:{testName}-{Guid.NewGuid():N}?mode=memory&cache=shared"; + var options = new SqliteAuditWriterOptions + { + DatabasePath = dataSource, + }; + // The writer uses raw "Data Source={path}" by appending Cache=Shared. Override + // by passing the full connection string via the connectionStringOverride hook. + var writer = new SqliteAuditWriter( + Options.Create(options), + NullLogger.Instance, + connectionStringOverride: $"Data Source={dataSource};Cache=Shared"); + return (writer, dataSource); + } + + private static SqliteConnection OpenVerifierConnection(string dataSource) + { + var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared"); + connection.Open(); + return connection; + } + + [Fact] + public void Opens_Creates_AuditLog_Table_With_20Columns_And_PK_On_EventId() + { + var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_20Columns_And_PK_On_EventId)); + using (writer) + { + using var connection = OpenVerifierConnection(dataSource); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "PRAGMA table_info(AuditLog);"; + using var reader = cmd.ExecuteReader(); + + var columns = new List<(string Name, int Pk)>(); + while (reader.Read()) + { + columns.Add((reader.GetString(1), reader.GetInt32(5))); + } + + Assert.Equal(20, columns.Count); + + var expected = new[] + { + "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", + "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", + "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", + "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", + "ForwardState", + }; + Assert.Equal(expected.OrderBy(n => n), columns.Select(c => c.Name).OrderBy(n => n)); + + // PK is EventId only. + var pkColumns = columns.Where(c => c.Pk > 0).Select(c => c.Name).ToList(); + Assert.Single(pkColumns); + Assert.Equal("EventId", pkColumns[0]); + } + } + + [Fact] + public void Opens_Creates_IX_ForwardState_Occurred_Index() + { + var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_IX_ForwardState_Occurred_Index)); + using (writer) + { + using var connection = OpenVerifierConnection(dataSource); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "PRAGMA index_list(AuditLog);"; + using var reader = cmd.ExecuteReader(); + + var indexNames = new List(); + while (reader.Read()) + { + indexNames.Add(reader.GetString(1)); + } + + Assert.Contains("IX_SiteAuditLog_ForwardState_Occurred", indexNames); + + // Verify the index columns are ForwardState, OccurredAtUtc in that order. + using var infoCmd = connection.CreateCommand(); + infoCmd.CommandText = "PRAGMA index_info(IX_SiteAuditLog_ForwardState_Occurred);"; + using var infoReader = infoCmd.ExecuteReader(); + + var indexColumns = new List(); + while (infoReader.Read()) + { + indexColumns.Add(infoReader.GetString(2)); + } + + Assert.Equal(new[] { "ForwardState", "OccurredAtUtc" }, indexColumns); + } + } + + [Fact] + public void PRAGMA_auto_vacuum_Is_INCREMENTAL() + { + var (writer, dataSource) = CreateWriter(nameof(PRAGMA_auto_vacuum_Is_INCREMENTAL)); + using (writer) + { + using var connection = OpenVerifierConnection(dataSource); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "PRAGMA auto_vacuum;"; + var value = Convert.ToInt32(cmd.ExecuteScalar()); + + // INCREMENTAL = 2 (0 = NONE, 1 = FULL, 2 = INCREMENTAL). + Assert.Equal(2, value); + } + } +} diff --git a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs new file mode 100644 index 0000000..b490142 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs @@ -0,0 +1,207 @@ +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ScadaLink.AuditLog.Site; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.AuditLog.Tests.Site; + +/// +/// Bundle B (M2-T2) hot-path tests for . Exercise +/// the Channel-based enqueue, the background writer's batch INSERTs, duplicate- +/// EventId swallowing, ForwardState defaulting, and the +/// / +/// support surface that +/// Bundle D's telemetry actor will call. +/// +public class SqliteAuditWriterWriteTests +{ + private static (SqliteAuditWriter writer, string dataSource) CreateWriter( + string testName, + int? channelCapacity = null) + { + var dataSource = $"file:{testName}-{Guid.NewGuid():N}?mode=memory&cache=shared"; + var opts = new SqliteAuditWriterOptions { DatabasePath = dataSource }; + if (channelCapacity is int cap) + { + opts.ChannelCapacity = cap; + } + + var writer = new SqliteAuditWriter( + Options.Create(opts), + NullLogger.Instance, + connectionStringOverride: $"Data Source={dataSource};Cache=Shared"); + return (writer, dataSource); + } + + private static SqliteConnection OpenVerifierConnection(string dataSource) + { + var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared"); + connection.Open(); + return connection; + } + + private static AuditEvent NewEvent(Guid? id = null, DateTime? occurredAtUtc = null) + { + return new AuditEvent + { + EventId = id ?? Guid.NewGuid(), + OccurredAtUtc = occurredAtUtc ?? DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Delivered, + PayloadTruncated = false, + }; + } + + [Fact] + public async Task WriteAsync_FreshEvent_PersistsWithForwardStatePending() + { + var (writer, dataSource) = CreateWriter(nameof(WriteAsync_FreshEvent_PersistsWithForwardStatePending)); + await using var _ = writer; + + var evt = NewEvent(); + await writer.WriteAsync(evt); + + using var connection = OpenVerifierConnection(dataSource); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT ForwardState FROM AuditLog WHERE EventId = $id;"; + cmd.Parameters.AddWithValue("$id", evt.EventId.ToString()); + var actual = cmd.ExecuteScalar() as string; + + Assert.Equal(AuditForwardState.Pending.ToString(), actual); + } + + [Fact] + public async Task WriteAsync_Concurrent_1000Calls_All_Persist_NoExceptions() + { + var (writer, dataSource) = CreateWriter(nameof(WriteAsync_Concurrent_1000Calls_All_Persist_NoExceptions)); + await using var _ = writer; + + var events = Enumerable.Range(0, 1000).Select(_ => NewEvent()).ToList(); + + await Parallel.ForEachAsync(events, new ParallelOptions { MaxDegreeOfParallelism = 16 }, + async (evt, ct) => await writer.WriteAsync(evt, ct)); + + using var connection = OpenVerifierConnection(dataSource); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM AuditLog;"; + var count = Convert.ToInt64(cmd.ExecuteScalar()); + + Assert.Equal(1000, count); + } + + [Fact] + public async Task WriteAsync_DuplicateEventId_FirstWriteWins_NoException() + { + var (writer, dataSource) = CreateWriter(nameof(WriteAsync_DuplicateEventId_FirstWriteWins_NoException)); + await using var _ = writer; + + var sharedId = Guid.NewGuid(); + var first = NewEvent(sharedId) with { Target = "first" }; + var second = NewEvent(sharedId) with { Target = "second" }; + + await writer.WriteAsync(first); + await writer.WriteAsync(second); + + using var connection = OpenVerifierConnection(dataSource); + using var countCmd = connection.CreateCommand(); + countCmd.CommandText = "SELECT COUNT(*) FROM AuditLog WHERE EventId = $id;"; + countCmd.Parameters.AddWithValue("$id", sharedId.ToString()); + var count = Convert.ToInt64(countCmd.ExecuteScalar()); + + Assert.Equal(1, count); + + using var targetCmd = connection.CreateCommand(); + targetCmd.CommandText = "SELECT Target FROM AuditLog WHERE EventId = $id;"; + targetCmd.Parameters.AddWithValue("$id", sharedId.ToString()); + Assert.Equal("first", targetCmd.ExecuteScalar() as string); + } + + [Fact] + public async Task WriteAsync_ForcesForwardStatePending_IfNull() + { + var (writer, dataSource) = CreateWriter(nameof(WriteAsync_ForcesForwardStatePending_IfNull)); + await using var _ = writer; + + var evt = NewEvent() with { ForwardState = null }; + await writer.WriteAsync(evt); + + using var connection = OpenVerifierConnection(dataSource); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT ForwardState FROM AuditLog WHERE EventId = $id;"; + cmd.Parameters.AddWithValue("$id", evt.EventId.ToString()); + + Assert.Equal(AuditForwardState.Pending.ToString(), cmd.ExecuteScalar() as string); + } + + [Fact] + public async Task ReadPendingAsync_Returns_OldestFirst_LimitedToN() + { + var (writer, _) = CreateWriter(nameof(ReadPendingAsync_Returns_OldestFirst_LimitedToN)); + await using var _writer = writer; + + var baseTime = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc); + var evts = new[] + { + NewEvent(occurredAtUtc: baseTime.AddSeconds(5)), + NewEvent(occurredAtUtc: baseTime.AddSeconds(1)), + NewEvent(occurredAtUtc: baseTime.AddSeconds(3)), + NewEvent(occurredAtUtc: baseTime.AddSeconds(2)), + NewEvent(occurredAtUtc: baseTime.AddSeconds(4)), + }; + + foreach (var e in evts) + { + await writer.WriteAsync(e); + } + + var rows = await writer.ReadPendingAsync(limit: 3); + + Assert.Equal(3, rows.Count); + Assert.Equal(baseTime.AddSeconds(1), rows[0].OccurredAtUtc); + Assert.Equal(baseTime.AddSeconds(2), rows[1].OccurredAtUtc); + Assert.Equal(baseTime.AddSeconds(3), rows[2].OccurredAtUtc); + } + + [Fact] + public async Task MarkForwardedAsync_FlipsRowsToForwarded() + { + var (writer, dataSource) = CreateWriter(nameof(MarkForwardedAsync_FlipsRowsToForwarded)); + await using var _ = writer; + + var ids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() }; + foreach (var id in ids) + { + await writer.WriteAsync(NewEvent(id)); + } + + await writer.MarkForwardedAsync(ids); + + using var connection = OpenVerifierConnection(dataSource); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT ForwardState, COUNT(*) FROM AuditLog GROUP BY ForwardState;"; + using var reader = cmd.ExecuteReader(); + var byState = new Dictionary(); + while (reader.Read()) + { + byState[reader.GetString(0)] = reader.GetInt64(1); + } + + Assert.Equal(3, byState[AuditForwardState.Forwarded.ToString()]); + Assert.False(byState.ContainsKey(AuditForwardState.Pending.ToString())); + } + + [Fact] + public async Task MarkForwardedAsync_NonExistentId_NoThrow() + { + var (writer, _) = CreateWriter(nameof(MarkForwardedAsync_NonExistentId_NoThrow)); + await using var _writer = writer; + + var phantomIds = new[] { Guid.NewGuid(), Guid.NewGuid() }; + + await writer.MarkForwardedAsync(phantomIds); + // No assertion needed: the call must complete without throwing. + } +} diff --git a/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/SiteAuditTelemetryActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/SiteAuditTelemetryActorTests.cs new file mode 100644 index 0000000..f8bef38 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/SiteAuditTelemetryActorTests.cs @@ -0,0 +1,235 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using ScadaLink.AuditLog.Site.Telemetry; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.Communication.Grpc; + +namespace ScadaLink.AuditLog.Tests.Site.Telemetry; + +/// +/// Bundle D D1 tests for . The actor drains +/// the site SQLite queue via , pushes batches via +/// , and flips ack'd rows to Forwarded. +/// Both collaborators are NSubstitute mocks so the tests never touch real +/// SQLite or gRPC. +/// +public class SiteAuditTelemetryActorTests : TestKit +{ + private readonly ISiteAuditQueue _queue = Substitute.For(); + private readonly ISiteStreamAuditClient _client = Substitute.For(); + + /// + /// Fast options so tests don't stall waiting for the scheduler. 1s busy / + /// 2s idle still exercises the busy-vs-idle branching, but each test + /// completes in < 5 s wall-clock. + /// + private static IOptions Opts( + int batchSize = 256, + int busySeconds = 1, + int idleSeconds = 2) => + Options.Create(new SiteAuditTelemetryOptions + { + BatchSize = batchSize, + BusyIntervalSeconds = busySeconds, + IdleIntervalSeconds = idleSeconds, + }); + + private IActorRef CreateActor(IOptions? options = null) => + Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor( + _queue, + _client, + options ?? Opts(), + NullLogger.Instance))); + + private static AuditEvent NewEvent(Guid? id = null) => new() + { + EventId = id ?? Guid.NewGuid(), + OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Delivered, + SourceSiteId = "site-1", + ForwardState = AuditForwardState.Pending, + }; + + private static IngestAck AckAll(IReadOnlyList events) + { + var ack = new IngestAck(); + foreach (var e in events) + { + ack.AcceptedEventIds.Add(e.EventId.ToString()); + } + return ack; + } + + [Fact] + public async Task Drain_With_50PendingRows_Sends_OneBatch_Of_50_Then_FlipsToForwarded() + { + // Arrange — 50 pending rows on the first read, then empty on subsequent + // reads so the actor settles after one productive drain. + var pending = Enumerable.Range(0, 50).Select(_ => NewEvent()).ToList(); + _queue.ReadPendingAsync(Arg.Any(), Arg.Any()) + .Returns( + Task.FromResult>(pending), + Task.FromResult>(Array.Empty())); + + AuditEventBatch? capturedBatch = null; + _client.IngestAuditEventsAsync(Arg.Any(), Arg.Any()) + .Returns(call => + { + capturedBatch = call.Arg(); + return Task.FromResult(AckAll(pending)); + }); + + // Act + CreateActor(); + + // Assert — give the scheduler time to fire the initial Drain tick. + await AwaitAssertAsync(async () => + { + await _client.Received(1).IngestAuditEventsAsync( + Arg.Any(), Arg.Any()); + await _queue.Received(1).MarkForwardedAsync( + Arg.Is>(g => g.Count == 50), Arg.Any()); + }, TimeSpan.FromSeconds(5)); + + Assert.NotNull(capturedBatch); + Assert.Equal(50, capturedBatch!.Events.Count); + + var expected = pending.Select(e => e.EventId).ToHashSet(); + await _queue.Received(1).MarkForwardedAsync( + Arg.Is>(g => g.ToHashSet().SetEquals(expected)), + Arg.Any()); + } + + [Fact] + public async Task Drain_GrpcThrows_RowsStayPending_NextDrainRetries() + { + // Arrange — first read returns 3 rows; the gRPC client throws on the + // first push, then succeeds on the second. After the second push the + // queue returns empty so the actor settles. + var batch = Enumerable.Range(0, 3).Select(_ => NewEvent()).ToList(); + _queue.ReadPendingAsync(Arg.Any(), Arg.Any()) + .Returns( + Task.FromResult>(batch), + Task.FromResult>(batch), + Task.FromResult>(Array.Empty())); + + var calls = 0; + _client.IngestAuditEventsAsync(Arg.Any(), Arg.Any()) + .Returns(_ => + { + calls++; + if (calls == 1) + { + throw new InvalidOperationException("simulated gRPC failure"); + } + return Task.FromResult(AckAll(batch)); + }); + + // Act + CreateActor(); + + // Assert — eventually MarkForwardedAsync is called exactly once (after + // the retry succeeded). The first failure must NOT have called + // MarkForwardedAsync because the rows stay Pending. + await AwaitAssertAsync(async () => + { + await _queue.Received(1).MarkForwardedAsync( + Arg.Any>(), Arg.Any()); + }, TimeSpan.FromSeconds(10)); + + Assert.True(calls >= 2, $"Expected at least 2 client calls (1 failure + 1 retry); saw {calls}"); + } + + [Fact] + public async Task Drain_ZeroPending_SchedulesAtIdleInterval_NoClientCall() + { + // Arrange — queue always empty. + _queue.ReadPendingAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + + // Idle interval = 2 s. Pause 3 s after the first tick (1 s busy on + // PreStart) and assert the empty-queue branch did NOT push to the + // client. + CreateActor(Opts(busySeconds: 1, idleSeconds: 2)); + + // Allow the initial tick (~1 s) + a generous window for the idle re-tick. + await Task.Delay(TimeSpan.FromSeconds(3)); + + await _client.DidNotReceiveWithAnyArgs().IngestAuditEventsAsync(default!, default); + + // ReadPendingAsync was called at least once (initial tick), and at + // most twice within the 3 s window (initial + one idle re-tick). + var readCalls = _queue.ReceivedCalls() + .Count(c => c.GetMethodInfo().Name == nameof(ISiteAuditQueue.ReadPendingAsync)); + Assert.InRange(readCalls, 1, 2); + } + + [Fact] + public async Task Drain_NonZeroPending_SchedulesAtBusyInterval() + { + // Arrange — every read returns 1 row. With busy=1s the actor should + // re-drain quickly, producing multiple client calls inside a short + // window. + var single = new List { NewEvent() }; + _queue.ReadPendingAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(single)); + + _client.IngestAuditEventsAsync(Arg.Any(), Arg.Any()) + .Returns(call => Task.FromResult(AckAll(single))); + + CreateActor(Opts(busySeconds: 1, idleSeconds: 10)); + + // 3-second window with busy=1s should fit at least 2 drains. + await Task.Delay(TimeSpan.FromSeconds(3)); + + var pushCalls = _client.ReceivedCalls() + .Count(c => c.GetMethodInfo().Name == nameof(ISiteStreamAuditClient.IngestAuditEventsAsync)); + Assert.True(pushCalls >= 2, + $"Expected ≥2 pushes within 3s when busy=1s; saw {pushCalls}"); + } + + [Fact] + public async Task Drain_AcceptedEventIdsSubset_OnlyMarksAccepted() + { + // Arrange — 5 rows pushed, but the central ack only lists 3. + var rows = Enumerable.Range(0, 5).Select(_ => NewEvent()).ToList(); + var ackedIds = rows.Take(3).Select(r => r.EventId).ToList(); + + _queue.ReadPendingAsync(Arg.Any(), Arg.Any()) + .Returns( + Task.FromResult>(rows), + Task.FromResult>(Array.Empty())); + + var partialAck = new IngestAck(); + foreach (var id in ackedIds) + { + partialAck.AcceptedEventIds.Add(id.ToString()); + } + _client.IngestAuditEventsAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(partialAck)); + + // Act + CreateActor(); + + await AwaitAssertAsync(async () => + { + await _queue.Received(1).MarkForwardedAsync( + Arg.Any>(), Arg.Any()); + }, TimeSpan.FromSeconds(5)); + + // Assert — exactly the 3 ack'd ids made it to MarkForwardedAsync, not + // the other 2. + var ackedSet = ackedIds.ToHashSet(); + await _queue.Received(1).MarkForwardedAsync( + Arg.Is>(g => g.Count == 3 && g.ToHashSet().SetEquals(ackedSet)), + Arg.Any()); + } +} diff --git a/tests/ScadaLink.AuditLog.Tests/Telemetry/AuditEventMapperTests.cs b/tests/ScadaLink.AuditLog.Tests/Telemetry/AuditEventMapperTests.cs new file mode 100644 index 0000000..6901361 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Telemetry/AuditEventMapperTests.cs @@ -0,0 +1,224 @@ +using Google.Protobuf.WellKnownTypes; +using ScadaLink.AuditLog.Telemetry; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.Communication.Grpc; + +namespace ScadaLink.AuditLog.Tests.Telemetry; + +/// +/// Round-trip + edge tests for the that bridges +/// (Commons) ↔ (proto). +/// ForwardState is site-local and IngestedAtUtc is central-set, so neither survives +/// the proto round-trip. +/// +public class AuditEventMapperTests +{ + [Fact] + public void ToDto_FromDto_Roundtrip_FullyPopulated_PreservesAllFields() + { + var occurredAt = new DateTime(2026, 5, 20, 10, 15, 30, 123, DateTimeKind.Utc); + var ingestedAt = new DateTime(2026, 5, 20, 10, 15, 31, 0, DateTimeKind.Utc); + var correlationId = Guid.NewGuid(); + var eventId = Guid.NewGuid(); + + var original = new AuditEvent + { + EventId = eventId, + OccurredAtUtc = occurredAt, + IngestedAtUtc = ingestedAt, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCallCached, + CorrelationId = correlationId, + SourceSiteId = "site-1", + SourceInstanceId = "Pump01", + SourceScript = "OnDemand", + Actor = "design-key", + Target = "weather-api", + Status = AuditStatus.Forwarded, + HttpStatus = 200, + DurationMs = 42, + ErrorMessage = "transient timeout", + ErrorDetail = "stack-trace", + RequestSummary = "GET /weather", + ResponseSummary = "{ \"ok\": true }", + PayloadTruncated = true, + Extra = "{ \"retryCount\": 1 }", + ForwardState = AuditForwardState.Pending + }; + + var dto = AuditEventMapper.ToDto(original); + var roundTripped = AuditEventMapper.FromDto(dto); + + Assert.Equal(original.EventId, roundTripped.EventId); + Assert.Equal(original.OccurredAtUtc, roundTripped.OccurredAtUtc); + Assert.Equal(original.Channel, roundTripped.Channel); + Assert.Equal(original.Kind, roundTripped.Kind); + Assert.Equal(original.CorrelationId, roundTripped.CorrelationId); + Assert.Equal(original.SourceSiteId, roundTripped.SourceSiteId); + Assert.Equal(original.SourceInstanceId, roundTripped.SourceInstanceId); + Assert.Equal(original.SourceScript, roundTripped.SourceScript); + Assert.Equal(original.Actor, roundTripped.Actor); + Assert.Equal(original.Target, roundTripped.Target); + Assert.Equal(original.Status, roundTripped.Status); + Assert.Equal(original.HttpStatus, roundTripped.HttpStatus); + Assert.Equal(original.DurationMs, roundTripped.DurationMs); + Assert.Equal(original.ErrorMessage, roundTripped.ErrorMessage); + Assert.Equal(original.ErrorDetail, roundTripped.ErrorDetail); + Assert.Equal(original.RequestSummary, roundTripped.RequestSummary); + Assert.Equal(original.ResponseSummary, roundTripped.ResponseSummary); + Assert.Equal(original.PayloadTruncated, roundTripped.PayloadTruncated); + Assert.Equal(original.Extra, roundTripped.Extra); + + // ForwardState + IngestedAtUtc are NOT carried in the proto contract. + Assert.Null(roundTripped.ForwardState); + Assert.Null(roundTripped.IngestedAtUtc); + } + + [Fact] + public void ToDto_NullableStringFields_BecomeEmptyString() + { + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.Notification, + Kind = AuditKind.NotifySend, + Status = AuditStatus.Submitted + // all string? fields left null; CorrelationId null + }; + + var dto = AuditEventMapper.ToDto(evt); + + Assert.Equal(string.Empty, dto.CorrelationId); + Assert.Equal(string.Empty, dto.SourceSiteId); + Assert.Equal(string.Empty, dto.SourceInstanceId); + Assert.Equal(string.Empty, dto.SourceScript); + Assert.Equal(string.Empty, dto.Actor); + Assert.Equal(string.Empty, dto.Target); + Assert.Equal(string.Empty, dto.ErrorMessage); + Assert.Equal(string.Empty, dto.ErrorDetail); + Assert.Equal(string.Empty, dto.RequestSummary); + Assert.Equal(string.Empty, dto.ResponseSummary); + Assert.Equal(string.Empty, dto.Extra); + } + + [Fact] + public void FromDto_EmptyString_BecomesNullProperty() + { + var dto = new AuditEventDto + { + EventId = Guid.NewGuid().ToString(), + OccurredAtUtc = Timestamp.FromDateTime(DateTime.UtcNow), + Channel = nameof(AuditChannel.ApiOutbound), + Kind = nameof(AuditKind.ApiCall), + Status = nameof(AuditStatus.Submitted), + CorrelationId = string.Empty, + SourceSiteId = string.Empty, + SourceInstanceId = string.Empty, + SourceScript = string.Empty, + Actor = string.Empty, + Target = string.Empty, + ErrorMessage = string.Empty, + ErrorDetail = string.Empty, + RequestSummary = string.Empty, + ResponseSummary = string.Empty, + Extra = string.Empty + }; + + var evt = AuditEventMapper.FromDto(dto); + + Assert.Null(evt.CorrelationId); + Assert.Null(evt.SourceSiteId); + Assert.Null(evt.SourceInstanceId); + Assert.Null(evt.SourceScript); + Assert.Null(evt.Actor); + Assert.Null(evt.Target); + Assert.Null(evt.ErrorMessage); + Assert.Null(evt.ErrorDetail); + Assert.Null(evt.RequestSummary); + Assert.Null(evt.ResponseSummary); + Assert.Null(evt.Extra); + } + + [Fact] + public void ToDto_OccurredAtUtc_PreservesUtcKind() + { + var occurredAt = new DateTime(2026, 5, 20, 8, 0, 0, DateTimeKind.Utc); + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = occurredAt, + Channel = AuditChannel.DbOutbound, + Kind = AuditKind.DbWrite, + Status = AuditStatus.Delivered + }; + + var dto = AuditEventMapper.ToDto(evt); + var roundTripped = AuditEventMapper.FromDto(dto); + + Assert.Equal(DateTimeKind.Utc, roundTripped.OccurredAtUtc.Kind); + Assert.Equal(occurredAt, roundTripped.OccurredAtUtc); + } + + [Fact] + public void ToDto_NullableInt_BecomesNullInt32Value() + { + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.Notification, + Kind = AuditKind.NotifySend, + Status = AuditStatus.Submitted, + HttpStatus = null, + DurationMs = null + }; + + var dto = AuditEventMapper.ToDto(evt); + + Assert.Null(dto.HttpStatus); + Assert.Null(dto.DurationMs); + } + + [Fact] + public void FromDto_NullInt32Value_BecomesNullProperty() + { + var dto = new AuditEventDto + { + EventId = Guid.NewGuid().ToString(), + OccurredAtUtc = Timestamp.FromDateTime(DateTime.UtcNow), + Channel = nameof(AuditChannel.ApiInbound), + Kind = nameof(AuditKind.InboundRequest), + Status = nameof(AuditStatus.Delivered) + // HttpStatus + DurationMs intentionally left absent + }; + + Assert.Null(dto.HttpStatus); + Assert.Null(dto.DurationMs); + + var evt = AuditEventMapper.FromDto(dto); + + Assert.Null(evt.HttpStatus); + Assert.Null(evt.DurationMs); + } + + [Fact] + public void ToDto_EnumValues_StoredAsStringNames() + { + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCallCached, + Status = AuditStatus.Parked + }; + + var dto = AuditEventMapper.ToDto(evt); + + Assert.Equal("ApiOutbound", dto.Channel); + Assert.Equal("ApiCallCached", dto.Kind); + Assert.Equal("Parked", dto.Status); + } +} diff --git a/tests/ScadaLink.Communication.Tests/Protos/AuditEventProtoTests.cs b/tests/ScadaLink.Communication.Tests/Protos/AuditEventProtoTests.cs new file mode 100644 index 0000000..4cd0d48 --- /dev/null +++ b/tests/ScadaLink.Communication.Tests/Protos/AuditEventProtoTests.cs @@ -0,0 +1,123 @@ +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using ScadaLink.Communication.Grpc; + +namespace ScadaLink.Communication.Tests.Protos; + +/// +/// Wire-format round-trip tests for the Audit Log (#23) telemetry proto messages +/// (, , ). +/// Locks the additive contract the site → central audit pipeline depends on. +/// +public class AuditEventProtoTests +{ + [Fact] + public void AuditEventDto_RoundTrip_PreservesAllFields() + { + var occurredAt = Timestamp.FromDateTimeOffset( + new DateTimeOffset(2026, 5, 20, 10, 15, 30, 123, TimeSpan.Zero)); + + var original = new AuditEventDto + { + EventId = Guid.NewGuid().ToString(), + OccurredAtUtc = occurredAt, + Channel = "ApiOutbound", + Kind = "ApiCall", + CorrelationId = Guid.NewGuid().ToString(), + SourceSiteId = "site-1", + SourceInstanceId = "Pump01", + SourceScript = "OnDemand", + Actor = "design-key", + Target = "weather-api", + Status = "Delivered", + HttpStatus = 200, + DurationMs = 42, + ErrorMessage = "no error", + ErrorDetail = "stack", + RequestSummary = "GET /weather?city=brisbane", + ResponseSummary = "{ \"temp\": 22.5 }", + PayloadTruncated = true, + Extra = "{ \"retryCount\": 0 }" + }; + + var bytes = original.ToByteArray(); + var deserialized = AuditEventDto.Parser.ParseFrom(bytes); + + Assert.Equal(original.EventId, deserialized.EventId); + Assert.Equal(original.OccurredAtUtc, deserialized.OccurredAtUtc); + Assert.Equal(original.Channel, deserialized.Channel); + Assert.Equal(original.Kind, deserialized.Kind); + Assert.Equal(original.CorrelationId, deserialized.CorrelationId); + Assert.Equal(original.SourceSiteId, deserialized.SourceSiteId); + Assert.Equal(original.SourceInstanceId, deserialized.SourceInstanceId); + Assert.Equal(original.SourceScript, deserialized.SourceScript); + Assert.Equal(original.Actor, deserialized.Actor); + Assert.Equal(original.Target, deserialized.Target); + Assert.Equal(original.Status, deserialized.Status); + Assert.Equal(original.HttpStatus, deserialized.HttpStatus); + Assert.Equal(original.DurationMs, deserialized.DurationMs); + Assert.Equal(original.ErrorMessage, deserialized.ErrorMessage); + Assert.Equal(original.ErrorDetail, deserialized.ErrorDetail); + Assert.Equal(original.RequestSummary, deserialized.RequestSummary); + Assert.Equal(original.ResponseSummary, deserialized.ResponseSummary); + Assert.Equal(original.PayloadTruncated, deserialized.PayloadTruncated); + Assert.Equal(original.Extra, deserialized.Extra); + } + + [Fact] + public void AuditEventDto_NullableInt_AbsentByDefault_NotIncludedInWire() + { + // Int32Value fields (http_status, duration_ms) are wrapper-typed in proto; + // when unset, the wrapper is absent, not serialized, and deserializes back to null. + var original = new AuditEventDto + { + EventId = Guid.NewGuid().ToString(), + OccurredAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Channel = "Notification", + Kind = "NotifySend", + Status = "Submitted" + }; + + Assert.Null(original.HttpStatus); + Assert.Null(original.DurationMs); + + var bytes = original.ToByteArray(); + var deserialized = AuditEventDto.Parser.ParseFrom(bytes); + + Assert.Null(deserialized.HttpStatus); + Assert.Null(deserialized.DurationMs); + } + + [Fact] + public void AuditEventBatch_Empty_RoundTrip_Yields_EmptyEvents() + { + var original = new AuditEventBatch(); + Assert.Empty(original.Events); + + var bytes = original.ToByteArray(); + var deserialized = AuditEventBatch.Parser.ParseFrom(bytes); + + Assert.Empty(deserialized.Events); + } + + [Fact] + public void IngestAck_PreservesAcceptedEventIds() + { + var id1 = Guid.NewGuid().ToString(); + var id2 = Guid.NewGuid().ToString(); + var id3 = Guid.NewGuid().ToString(); + + var original = new IngestAck(); + original.AcceptedEventIds.Add(id1); + original.AcceptedEventIds.Add(id2); + original.AcceptedEventIds.Add(id3); + + var bytes = original.ToByteArray(); + var deserialized = IngestAck.Parser.ParseFrom(bytes); + + Assert.Equal(3, deserialized.AcceptedEventIds.Count); + Assert.Equal(id1, deserialized.AcceptedEventIds[0]); + Assert.Equal(id2, deserialized.AcceptedEventIds[1]); + Assert.Equal(id3, deserialized.AcceptedEventIds[2]); + } +} diff --git a/tests/ScadaLink.Communication.Tests/SiteStreamIngestAuditEventsTests.cs b/tests/ScadaLink.Communication.Tests/SiteStreamIngestAuditEventsTests.cs new file mode 100644 index 0000000..df1f049 --- /dev/null +++ b/tests/ScadaLink.Communication.Tests/SiteStreamIngestAuditEventsTests.cs @@ -0,0 +1,100 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using ScadaLink.Commons.Messages.Audit; +using ScadaLink.Communication.Grpc; + +namespace ScadaLink.Communication.Tests; + +/// +/// Bundle D D2 tests for . +/// Verifies the DTO→entity→actor→ack round-trip through the gRPC handler. +/// A tiny StubIngestActor stands in for the central +/// AuditLogIngestActor, replying with the EventIds it received so the +/// test asserts the wiring without depending on MSSQL. +/// +public class SiteStreamIngestAuditEventsTests : TestKit +{ + private readonly ISiteStreamSubscriber _subscriber = Substitute.For(); + + private SiteStreamGrpcServer CreateServer() => + new(_subscriber, NullLogger.Instance); + + private static ServerCallContext NewContext(CancellationToken ct = default) + { + var context = Substitute.For(); + context.CancellationToken.Returns(ct); + return context; + } + + private static AuditEventDto NewDto(Guid? id = null) => new() + { + EventId = (id ?? Guid.NewGuid()).ToString(), + OccurredAtUtc = Timestamp.FromDateTime( + DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)), + Channel = "ApiOutbound", + Kind = "ApiCall", + Status = "Delivered", + SourceSiteId = "site-1", + }; + + [Fact] + public async Task IngestAuditEvents_With_AuditIngestActor_Routes_To_Actor_Returns_Reply() + { + // Arrange — a stub actor that echoes every received EventId back. + var stubActor = Sys.ActorOf(Props.Create(() => new EchoIngestActor())); + + var server = CreateServer(); + server.SetAuditIngestActor(stubActor); + + // Build a 3-event batch. + var dtos = Enumerable.Range(0, 3).Select(_ => NewDto()).ToList(); + var batch = new AuditEventBatch(); + batch.Events.AddRange(dtos); + + // Act + var ack = await server.IngestAuditEvents(batch, NewContext()); + + // Assert — every dto's id appears in the ack, demonstrating end-to- + // end routing through the actor. + Assert.Equal(3, ack.AcceptedEventIds.Count); + var expectedIds = dtos.Select(d => d.EventId).ToHashSet(); + Assert.True(expectedIds.SetEquals(ack.AcceptedEventIds.ToHashSet())); + } + + [Fact] + public async Task IngestAuditEvents_NoActor_Wired_ReturnsEmptyAck() + { + var server = CreateServer(); + // Intentionally do NOT call SetAuditIngestActor — simulates host + // startup race window. + + var batch = new AuditEventBatch(); + batch.Events.Add(NewDto()); + + var ack = await server.IngestAuditEvents(batch, NewContext()); + + Assert.Empty(ack.AcceptedEventIds); + } + + /// + /// Tiny ReceiveActor that echoes every EventId in an incoming + /// back as an + /// . Stands in for the central + /// AuditLogIngestActor so this test never touches MSSQL. + /// + private sealed class EchoIngestActor : ReceiveActor + { + public EchoIngestActor() + { + Receive(cmd => + { + var ids = cmd.Events.Select(e => e.EventId).ToList(); + Sender.Tell(new IngestAuditEventsReply(ids)); + }); + } + } +} diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs index 6bf8db4..958b2b1 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs @@ -217,6 +217,98 @@ public class AuditLogRepositoryTests : IClassFixture Assert.Equal(t0.AddMinutes(0), page3[0].OccurredAtUtc); } + [SkippableFact] + public async Task InsertIfNotExistsAsync_ConcurrentDuplicateInserts_ProduceExactlyOneRow() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + + // Single event used by every parallel call — same EventId, same payload. + // The repository's IF NOT EXISTS … INSERT pattern has a check-then-act + // race window between sessions; under concurrent load SQL Server can + // raise a unique-index violation (error 2601) on UX_AuditLog_EventId. + // Bundle A's hardening swallows 2601/2627 so duplicates collapse silently. + var evt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc)); + + // 50 parallel inserters, each with its own DbContext (DbContext is not + // thread-safe). Parallel.ForEachAsync aggregates exceptions, so a single + // unhandled 2601 from the repository would fail this test loudly. + await Parallel.ForEachAsync( + Enumerable.Range(0, 50), + new ParallelOptions { MaxDegreeOfParallelism = 50 }, + async (_, ct) => + { + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + await repo.InsertIfNotExistsAsync(evt, ct); + }); + + await using var readContext = CreateContext(); + var count = await readContext.Set() + .Where(e => e.SourceSiteId == siteId) + .CountAsync(); + + Assert.Equal(1, count); + } + + [SkippableFact] + public async Task QueryAsync_Keyset_SameOccurredAtUtc_TiebreaksOnEventId() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + // Four events all sharing the exact same OccurredAtUtc — the keyset + // cursor must lean on the EventId tiebreaker (descending) to page + // deterministically. Bundle D's reviewer flagged this as a deferred + // verification because it depends on EF Core 10 translating + // Guid.CompareTo against SQL Server's uniqueidentifier sort order. + var occurredAt = new DateTime(2026, 5, 20, 13, 0, 0, DateTimeKind.Utc); + + // Build four distinct Guids; we don't care about the literal ordering + // produced by Guid.CompareTo — only that paging is deterministic and + // covers every row exactly once. + var events = Enumerable.Range(0, 4) + .Select(_ => NewEvent(siteId, occurredAtUtc: occurredAt)) + .ToList(); + + foreach (var e in events) + { + await repo.InsertIfNotExistsAsync(e); + } + + var filter = new AuditLogQueryFilter(SourceSiteId: siteId); + + var page1 = await repo.QueryAsync(filter, new AuditLogPaging(PageSize: 2)); + Assert.Equal(2, page1.Count); + Assert.All(page1, r => Assert.Equal(occurredAt, r.OccurredAtUtc)); + + var cursor = page1[^1]; + var page2 = await repo.QueryAsync( + filter, + new AuditLogPaging( + PageSize: 2, + AfterOccurredAtUtc: cursor.OccurredAtUtc, + AfterEventId: cursor.EventId)); + + Assert.Equal(2, page2.Count); + Assert.All(page2, r => Assert.Equal(occurredAt, r.OccurredAtUtc)); + + var page1Ids = page1.Select(r => r.EventId).ToHashSet(); + var page2Ids = page2.Select(r => r.EventId).ToHashSet(); + + // No overlap between pages. + Assert.Empty(page1Ids.Intersect(page2Ids)); + + // Every inserted EventId appears in exactly one of the two pages. + var allIds = page1Ids.Union(page2Ids).ToHashSet(); + Assert.Equal(4, allIds.Count); + Assert.True(events.Select(e => e.EventId).ToHashSet().SetEquals(allIds)); + } + [SkippableFact] public async Task SwitchOutPartitionAsync_ThrowsNotSupported_ForM1() { diff --git a/tests/ScadaLink.HealthMonitoring.Tests/SiteAuditWriteFailuresMetricTests.cs b/tests/ScadaLink.HealthMonitoring.Tests/SiteAuditWriteFailuresMetricTests.cs new file mode 100644 index 0000000..0fdb533 --- /dev/null +++ b/tests/ScadaLink.HealthMonitoring.Tests/SiteAuditWriteFailuresMetricTests.cs @@ -0,0 +1,52 @@ +namespace ScadaLink.HealthMonitoring.Tests; + +/// +/// Bundle G (M2-T11) regression coverage. The site-side Audit Log writer chain +/// (FallbackAuditWriter) increments +/// every time the primary SQLite writer throws. Bundle G bridges that counter +/// into the Site Health Monitoring report payload as SiteAuditWriteFailures +/// so a sustained audit-write outage surfaces on /monitoring/health rather than +/// disappearing into a NoOp sink. +/// +public class SiteAuditWriteFailuresMetricTests +{ + private readonly SiteHealthCollector _collector = new(); + + [Fact] + public void Increment_Three_Times_Counter_Reports_3() + { + _collector.IncrementSiteAuditWriteFailures(); + _collector.IncrementSiteAuditWriteFailures(); + _collector.IncrementSiteAuditWriteFailures(); + + var report = _collector.CollectReport("site-1"); + + Assert.Equal(3, report.SiteAuditWriteFailures); + } + + [Fact] + public void Report_Payload_Includes_SiteAuditWriteFailures_AsZeroByDefault() + { + var report = _collector.CollectReport("site-1"); + + Assert.Equal(0, report.SiteAuditWriteFailures); + } + + /// + /// Mirrors the existing per-interval reset semantics for ScriptErrorCount / + /// AlarmEvaluationErrorCount / DeadLetterCount — SiteAuditWriteFailures is an + /// interval count, not a running total. + /// + [Fact] + public void CollectReport_Resets_SiteAuditWriteFailures() + { + _collector.IncrementSiteAuditWriteFailures(); + _collector.IncrementSiteAuditWriteFailures(); + + var first = _collector.CollectReport("site-1"); + Assert.Equal(2, first.SiteAuditWriteFailures); + + var second = _collector.CollectReport("site-1"); + Assert.Equal(0, second.SiteAuditWriteFailures); + } +} diff --git a/tests/ScadaLink.Host.Tests/AkkaHostedServiceAuditWiringTests.cs b/tests/ScadaLink.Host.Tests/AkkaHostedServiceAuditWiringTests.cs new file mode 100644 index 0000000..392dc38 --- /dev/null +++ b/tests/ScadaLink.Host.Tests/AkkaHostedServiceAuditWiringTests.cs @@ -0,0 +1,306 @@ +using Akka.Configuration; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using ScadaLink.AuditLog; +using ScadaLink.AuditLog.Site; +using ScadaLink.AuditLog.Site.Telemetry; +using ScadaLink.ClusterInfrastructure; +using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.ConfigurationDatabase; +using ScadaLink.Host; +using ScadaLink.Host.Actors; + +namespace ScadaLink.Host.Tests; + +/// +/// Bundle E (M2 Task E1) — verifies the Audit Log (#23) DI surface is wired +/// into both composition roots and that the HOCON document emitted by +/// includes the dedicated +/// audit-telemetry-dispatcher the site telemetry actor binds to. +/// +/// +/// +/// Full cluster bring-up is exercised by the existing +/// pattern — these tests reuse the same +/// trick to short-circuit +/// so DI resolution is exercised +/// without the actor system actually being created. +/// +/// +public class AkkaHostedServiceAuditWiringHoconTests +{ + [Fact] + public void BuildHocon_Emits_AuditTelemetryDispatcher_Block() + { + // Bundle E acceptance: the HOCON document the host parses must declare + // the dedicated dispatcher the SiteAuditTelemetryActor binds to. A + // missing dispatcher block would route the actor to the default + // dispatcher and silently lose the isolation guarantee. + var nodeOptions = new NodeOptions + { + Role = "Site", + NodeHostname = "site-test-1", + RemotingPort = 0, + SiteId = "TestSite", + }; + var clusterOptions = new ClusterOptions + { + SeedNodes = new List { "akka.tcp://scadalink@localhost:2551" }, + SplitBrainResolverStrategy = "keep-oldest", + MinNrOfMembers = 1, + StableAfter = TimeSpan.FromSeconds(15), + HeartbeatInterval = TimeSpan.FromSeconds(2), + FailureDetectionThreshold = TimeSpan.FromSeconds(10), + }; + + var hocon = AkkaHostedService.BuildHocon( + nodeOptions, + clusterOptions, + new[] { "Site", "site-TestSite" }, + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(15)); + + var config = ConfigurationFactory.ParseString(hocon); + + // The dispatcher is declared at the root, so the lookup is by its + // unqualified name. The HOCON parser must accept the block as a + // standalone dispatcher definition the actor system can resolve. + var dispatcherType = config.GetString("audit-telemetry-dispatcher.type"); + Assert.Equal("ForkJoinDispatcher", dispatcherType); + + var throughput = config.GetInt("audit-telemetry-dispatcher.throughput"); + Assert.Equal(100, throughput); + + var threadCount = config.GetInt("audit-telemetry-dispatcher.dedicated-thread-pool.thread-count"); + Assert.Equal(2, threadCount); + } +} + +/// +/// Verifies Audit Log (#23) services land in the Central composition root. +/// +public class CentralAuditWiringTests : IDisposable +{ + private readonly WebApplicationFactory _factory; + private readonly string? _previousEnv; + + public CentralAuditWiringTests() + { + _previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central"); + + _factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["ScadaLink:Node:NodeHostname"] = "localhost", + ["ScadaLink:Node:RemotingPort"] = "0", + ["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:2551", + ["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:2552", + ["ScadaLink:Database:SkipMigrations"] = "true", + ["ScadaLink:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!", + ["ScadaLink:Security:LdapServer"] = "localhost", + ["ScadaLink:Security:LdapPort"] = "3893", + ["ScadaLink:Security:LdapUseTls"] = "false", + ["ScadaLink:Security:AllowInsecureLdap"] = "true", + ["ScadaLink:Security:LdapSearchBase"] = "dc=scadalink,dc=local", + ["ScadaLink:InboundApi:ApiKeyPepper"] = "test-inbound-api-key-pepper-at-least-32-chars!", + }); + }); + builder.UseSetting("ScadaLink:Node:Role", "Central"); + builder.UseSetting("ScadaLink:Database:SkipMigrations", "true"); + builder.ConfigureServices(services => + { + var descriptorsToRemove = services + .Where(d => + d.ServiceType == typeof(DbContextOptions) || + d.ServiceType == typeof(DbContextOptions) || + d.ServiceType == typeof(ScadaLinkDbContext) || + d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true) + .ToList(); + foreach (var d in descriptorsToRemove) + services.Remove(d); + + services.AddDbContext(options => + options.UseInMemoryDatabase($"CentralAuditWiringTests_{Guid.NewGuid()}")); + + AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services); + }); + }); + + _ = _factory.Server; + } + + public void Dispose() + { + _factory.Dispose(); + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv); + } + + [Fact] + public void Central_Resolves_IAuditWriter_AsFallbackAuditWriter() + { + // Central nodes still register the writer chain because AddAuditLog is + // shared between roles — the registrations are lazy singletons and the + // writer is never resolved on a central node in production. Asserting + // it resolves here confirms the chain is intact and ready for the + // future case where a central-only actor needs to emit audit events. + var writer = _factory.Services.GetService(); + Assert.NotNull(writer); + Assert.IsType(writer); + } + + [Fact] + public void Central_Resolves_AuditLogOptions() + { + var opts = _factory.Services.GetService>(); + Assert.NotNull(opts); + Assert.NotNull(opts!.Value); + } + + [Fact] + public void Central_Resolves_SqliteAuditWriterOptions() + { + var opts = _factory.Services.GetService>(); + Assert.NotNull(opts); + Assert.NotNull(opts!.Value); + } + + [Fact] + public void Central_Resolves_SiteAuditTelemetryOptions() + { + var opts = _factory.Services.GetService>(); + Assert.NotNull(opts); + Assert.NotNull(opts!.Value); + } + + [Fact] + public void Central_Resolves_ISiteStreamAuditClient_AsNoOpDefault() + { + var client = _factory.Services.GetService(); + Assert.NotNull(client); + Assert.IsType(client); + } +} + +/// +/// Verifies Audit Log (#23) services land in the Site composition root. +/// +public class SiteAuditWiringTests : IDisposable +{ + private readonly WebApplication _host; + private readonly string _tempDbPath; + + public SiteAuditWiringTests() + { + _tempDbPath = Path.Combine(Path.GetTempPath(), $"scadalink_audit_wiring_{Guid.NewGuid()}.db"); + + var builder = WebApplication.CreateBuilder(); + builder.Configuration.Sources.Clear(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["ScadaLink:Node:Role"] = "Site", + ["ScadaLink:Node:NodeHostname"] = "test-site", + ["ScadaLink:Node:SiteId"] = "TestSite", + ["ScadaLink:Node:RemotingPort"] = "0", + ["ScadaLink:Node:GrpcPort"] = "0", + ["ScadaLink:Database:SiteDbPath"] = _tempDbPath, + ["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:2551", + ["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:2552", + // SqliteAuditWriter would attempt to open a SQLite file when first + // resolved; point it at an in-memory connection so the test doesn't + // pollute the working directory. + ["AuditLog:SiteWriter:DatabasePath"] = ":memory:", + }); + + builder.Services.AddGrpc(); + builder.Services.AddSingleton(); + SiteServiceRegistration.Configure(builder.Services, builder.Configuration); + AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services); + + _host = builder.Build(); + } + + public void Dispose() + { + (_host as IDisposable)?.Dispose(); + try { File.Delete(_tempDbPath); } catch { /* best effort */ } + } + + [Fact] + public void Site_Resolves_IAuditWriter_AsFallbackAuditWriter() + { + var writer = _host.Services.GetService(); + Assert.NotNull(writer); + Assert.IsType(writer); + } + + [Fact] + public void Site_Resolves_SqliteAuditWriter_AsSingleton() + { + var a = _host.Services.GetService(); + var b = _host.Services.GetService(); + Assert.NotNull(a); + Assert.NotNull(b); + Assert.Same(a, b); + } + + [Fact] + public void Site_ISiteAuditQueue_AndSqliteAuditWriter_AreSameInstance() + { + // The telemetry actor reads from ISiteAuditQueue while ScriptRuntimeContext + // writes through IAuditWriter → SqliteAuditWriter. If these don't resolve + // to the same instance, pending rows are invisible to the actor. + var queue = _host.Services.GetService(); + var writer = _host.Services.GetService(); + Assert.NotNull(queue); + Assert.NotNull(writer); + Assert.Same(writer, queue); + } + + [Fact] + public void Site_Resolves_RingBufferFallback() + { + var ring = _host.Services.GetService(); + Assert.NotNull(ring); + } + + [Fact] + public void Site_Resolves_IAuditWriteFailureCounter_AsHealthMetricsBridge() + { + // Bundle G (M2-T11): site composition root calls + // AddAuditLogHealthMetricsBridge() after AddAuditLog + AddSiteHealthMonitoring, + // which swaps the NoOp default for the real health-metrics bridge so + // FallbackAuditWriter primary failures surface in the site health + // report payload as SiteAuditWriteFailures. + var counter = _host.Services.GetService(); + Assert.NotNull(counter); + Assert.IsType(counter); + } + + [Fact] + public void Site_Resolves_ISiteStreamAuditClient_AsNoOpDefault() + { + var client = _host.Services.GetService(); + Assert.NotNull(client); + Assert.IsType(client); + } + + [Fact] + public void Site_Resolves_SiteAuditTelemetryOptions_WithDefaults() + { + var opts = _host.Services.GetService>(); + Assert.NotNull(opts); + Assert.Equal(256, opts!.Value.BatchSize); + Assert.Equal(5, opts.Value.BusyIntervalSeconds); + Assert.Equal(30, opts.Value.IdleIntervalSeconds); + } +} diff --git a/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerRedeployTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerRedeployTests.cs index 7778c65..9548631 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerRedeployTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerRedeployTests.cs @@ -69,6 +69,7 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable public void IncrementScriptError() { } public void IncrementAlarmError() { } public void IncrementDeadLetter() { } + public void IncrementSiteAuditWriteFailures() { } public void UpdateConnectionHealth(string connectionName, ConnectionHealth health) { } public void RemoveConnection(string connectionName) { } public void UpdateTagResolution(string connectionName, int totalSubscribed, int successfullyResolved) { } diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs new file mode 100644 index 0000000..59a0e7a --- /dev/null +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs @@ -0,0 +1,214 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.Commons.Types; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.SiteRuntime.Scripts; + +namespace ScadaLink.SiteRuntime.Tests.Scripts; + +/// +/// Audit Log #23 — M2 Bundle F (Task F1): every script-initiated +/// ExternalSystem.Call emits exactly one ApiOutbound/ApiCall +/// audit event via the wrapper inside +/// . The audit emission +/// is best-effort: a thrown must never +/// abort the script's call, and the original +/// (or original exception) must surface to the caller unchanged. +/// +public class ExternalSystemCallAuditEmissionTests +{ + /// + /// In-memory that records every event passed to + /// . Optionally configurable to throw, simulating a + /// catastrophic audit-writer failure that the wrapper must swallow. + /// + private sealed class CapturingAuditWriter : IAuditWriter + { + public List 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:CheckPressure"; + + private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper( + IExternalSystemClient client, + IAuditWriter? auditWriter) + { + return new ScriptRuntimeContext.ExternalSystemHelper( + client, + InstanceName, + NullLogger.Instance, + auditWriter, + SiteId, + SourceScript); + } + + [Fact] + public async Task Call_Success_EmitsOneEvent_Channel_ApiOutbound_Kind_ApiCall_Status_Delivered() + { + var client = new Mock(); + client + .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ExternalCallResult(true, "{}", null)); + var writer = new CapturingAuditWriter(); + + var helper = CreateHelper(client.Object, writer); + var result = await helper.Call("ERP", "GetOrder"); + + Assert.True(result.Success); + Assert.Single(writer.Events); + var evt = writer.Events[0]; + Assert.Equal(AuditChannel.ApiOutbound, evt.Channel); + Assert.Equal(AuditKind.ApiCall, evt.Kind); + Assert.Equal(AuditStatus.Delivered, evt.Status); + Assert.Equal("ERP.GetOrder", evt.Target); + Assert.Equal(AuditForwardState.Pending, evt.ForwardState); + Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind); + Assert.NotEqual(Guid.Empty, evt.EventId); + Assert.False(evt.PayloadTruncated); + } + + [Fact] + public async Task Call_HTTP500_EmitsEvent_Status_Failed_HttpStatus_500_ErrorMessage_Set() + { + var client = new Mock(); + client + .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ExternalCallResult(false, null, "Transient error: HTTP 500 from ERP: Internal Server Error")); + var writer = new CapturingAuditWriter(); + + var helper = CreateHelper(client.Object, writer); + var result = await helper.Call("ERP", "GetOrder"); + + Assert.False(result.Success); + Assert.Single(writer.Events); + var evt = writer.Events[0]; + Assert.Equal(AuditStatus.Failed, evt.Status); + Assert.Equal(500, evt.HttpStatus); + Assert.False(string.IsNullOrEmpty(evt.ErrorMessage)); + Assert.Contains("500", evt.ErrorMessage); + } + + [Fact] + public async Task Call_HTTP400_EmitsEvent_Status_Failed_HttpStatus_400() + { + var client = new Mock(); + client + .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ExternalCallResult(false, null, "Permanent error: HTTP 400 from ERP: Bad Request")); + var writer = new CapturingAuditWriter(); + + var helper = CreateHelper(client.Object, writer); + var result = await helper.Call("ERP", "GetOrder"); + + Assert.False(result.Success); + Assert.Single(writer.Events); + var evt = writer.Events[0]; + Assert.Equal(AuditStatus.Failed, evt.Status); + Assert.Equal(400, evt.HttpStatus); + } + + [Fact] + public async Task Call_ClientThrows_NetworkException_EmitsEvent_Status_Failed_ErrorMessage_FromException() + { + var client = new Mock(); + var networkEx = new HttpRequestException("network down"); + client + .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) + .ThrowsAsync(networkEx); + var writer = new CapturingAuditWriter(); + + var helper = CreateHelper(client.Object, writer); + var thrown = await Assert.ThrowsAsync(() => helper.Call("ERP", "GetOrder")); + Assert.Same(networkEx, thrown); + + Assert.Single(writer.Events); + var evt = writer.Events[0]; + Assert.Equal(AuditStatus.Failed, evt.Status); + Assert.Null(evt.HttpStatus); + Assert.Equal("network down", evt.ErrorMessage); + Assert.NotNull(evt.ErrorDetail); + Assert.Contains("HttpRequestException", evt.ErrorDetail); + } + + [Fact] + public async Task AuditWriter_Throws_Script_Call_Returns_Original_Result_Unchanged() + { + var client = new Mock(); + var expected = new ExternalCallResult(true, "{\"v\":1}", null); + client + .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(expected); + var writer = new CapturingAuditWriter + { + ThrowOnWrite = new InvalidOperationException("audit writer down") + }; + + var helper = CreateHelper(client.Object, writer); + var result = await helper.Call("ERP", "GetOrder"); + + Assert.Same(expected, result); + Assert.Empty(writer.Events); + } + + [Fact] + public async Task Provenance_Populated_FromContext() + { + var client = new Mock(); + client + .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ExternalCallResult(true, null, null)); + var writer = new CapturingAuditWriter(); + + var helper = CreateHelper(client.Object, writer); + var beforeId = Guid.NewGuid(); + + await helper.Call("ERP", "GetOrder"); + + var evt = writer.Events[0]; + Assert.NotEqual(beforeId, evt.EventId); + Assert.NotEqual(Guid.Empty, evt.EventId); + Assert.Equal(SiteId, evt.SourceSiteId); + Assert.Equal(InstanceName, evt.SourceInstanceId); + Assert.Equal(SourceScript, evt.SourceScript); + Assert.Null(evt.Actor); + Assert.Null(evt.CorrelationId); + } + + [Fact] + public async Task DurationMs_Recorded_NonZero() + { + var client = new Mock(); + client + .Setup(c => c.CallAsync("ERP", "Slow", It.IsAny?>(), It.IsAny())) + .Returns(async () => + { + await Task.Delay(20); + return new ExternalCallResult(true, null, null); + }); + var writer = new CapturingAuditWriter(); + + var helper = CreateHelper(client.Object, writer); + await helper.Call("ERP", "Slow"); + + var evt = writer.Events[0]; + 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"); + } +}