From 6ffa47f2587cb96b4f4a1e73e9c3e6e9d21e87b0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 14:34:12 -0400 Subject: [PATCH 01/15] docs(design): Audit Log ExecutionId universal correlation --- .../2026-05-21-audit-executionid-design.md | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 docs/plans/2026-05-21-audit-executionid-design.md diff --git a/docs/plans/2026-05-21-audit-executionid-design.md b/docs/plans/2026-05-21-audit-executionid-design.md new file mode 100644 index 0000000..c2ef66f --- /dev/null +++ b/docs/plans/2026-05-21-audit-executionid-design.md @@ -0,0 +1,115 @@ +# Audit Log — ExecutionId Universal Correlation (Design) + +**Date:** 2026-05-21 +**Status:** Validated — ready for implementation planning. + +## Problem + +The audit `CorrelationId` column is overloaded with three incompatible meanings — +`TrackedOperationId` for cached calls, `NotificationId` for notifications, the +script-execution id for sync calls (added 2026-05-21), and request-local ids for +inbound. It is `NULL` for sync one-shot calls. There is no single value that ties +together *everything one script run (or inbound request) did*: a run that makes a +sync API call, a cached call and a notification produces three unrelated +correlation ids, and nothing links the cached call's lifecycle rows back to the +run that launched them. + +A single `CorrelationId` column cannot serve both scopes — the **operation +lifecycle** (a cached call's `Submit→Attempted→Resolve`; a notification's +`Send→Deliver`, which the Site Calls / Notifications "View audit history" +drill-ins depend on) and the **execution trace** (all operations of one run). + +## Decision + +Add a dedicated, nullable **`ExecutionId`** column to the audit row. It identifies +the originating **script execution** or **inbound API request**. Every audit row +that execution produces carries the same `ExecutionId`. `CorrelationId` is left +exactly as it is — it keeps the per-operation lifecycle meaning, so the existing +operation drill-ins are unaffected. + +Result: `WHERE ExecutionId = X` returns every audit row of one run — sync +`ApiCall`/`DbWrite`, the whole cached-call lifecycle, `NotifySend`, +`NotifyDeliver`, and the inbound row — across both the site and central tables. + +`ScriptRuntimeContext` already holds a per-execution id (`_auditCorrelationId`, +added 2026-05-21). That id becomes the `ExecutionId`; this work stamps it into the +new column from every emitter and threads it to the two paths where the script +context is not in scope. + +### Considered and rejected + +- **Overload `CorrelationId`** with the execution id everywhere — breaks the + cached-call / notification "View audit history" drill-ins (they filter + `CorrelationId` by `TrackedOperationId` / `NotificationId`), or forces them to + show the whole run instead of the one operation. +- **Stash the execution id in `Extra` JSON** — no schema change, but `Extra` is + unindexed; filtering an audit table of this volume by it is unworkable. + +## Schema changes (all additive, nullable — no backfill; pre-existing rows stay `NULL`) + +| Where | Change | +|---|---| +| `ScadaLink.Commons` | `AuditEvent` record (and the site-local variant) gains `Guid? ExecutionId`. | +| Central MS SQL `AuditLog` | new `ExecutionId uniqueidentifier NULL` column + index `IX_AuditLog_Execution (ExecutionId)`. EF migration — additive nullable column is a metadata-only `ALTER`, fast even on the monthly-partitioned table. | +| Site SQLite `auditlog.db` `AuditLog` | new `ExecutionId TEXT NULL` column (`SqliteAuditWriter` schema + `MapRow`). | +| gRPC `AuditEventDto` (`sitestream.proto`) | additive `execution_id` field; `AuditEventDtoMapper` maps it both directions. | +| Central MS SQL `Notifications` | new `OriginExecutionId uniqueidentifier NULL` column — carries the originating run's id so the dispatcher can echo it onto `NotifyDeliver` audit rows. EF migration. | + +`SiteCalls` needs no new column — the cached telemetry packet already carries the +audit half, which now has `ExecutionId` directly. + +## Emitter coverage — every audit row carries `ExecutionId` + +| Emitter | `ExecutionId` source | +|---|---| +| Sync `ApiCall`, sync `DbWrite` | `ScriptRuntimeContext` execution id (in scope today) | +| Cached call script-side rows (`CachedSubmit`, immediate `Attempted`/`CachedResolve`) | `ScriptRuntimeContext` execution id | +| Cached call **S&F retry-loop** rows (`CachedCallLifecycleBridge`) | threaded through the store-and-forward buffered message → `CachedCallAttemptContext` → the bridge. This same threading also fixes the pre-existing `SourceScript = NULL` gap on those rows (identical boundary). | +| `NotifySend` (site, script-side) | `ScriptRuntimeContext` execution id | +| `NotifyDeliver` (central dispatch) | `Notifications.OriginExecutionId` — the id rides on `NotificationSubmit`, is persisted on the `Notifications` row, and the dispatcher stamps it on every `NotifyDeliver` row | +| Inbound `InboundRequest` / `InboundAuthFailure` | request id minted once in `AuditWriteMiddleware` | + +## Data flow + +- **Site script run** — `ScriptRuntimeContext` generates the execution id (or is + given one); every emitter it owns stamps `ExecutionId`. +- **Buffered cached call** — the execution id rides on the S&F buffered message; + the retry loop reconstructs it into `CachedCallAttemptContext`; + `CachedCallLifecycleBridge` stamps it on the retry-loop audit rows. +- **Notification** — the `NotifySend` row stamps it site-side; the id travels on + `NotificationSubmit`, is stored as `Notifications.OriginExecutionId`, and the + dispatcher stamps every `NotifyDeliver` row it emits. +- **Inbound API request** — `AuditWriteMiddleware` mints a request id and stamps + the inbound audit row. + +## UI / CLI surface + +- **Central UI Audit Log page** — `ExecutionId` added as a results-grid column + (the grid already supports resize/reorder); an `ExecutionId` paste-filter in + the filter bar; the page accepts `?executionId=`; a row drill-in + "View this execution" → `/audit/log?executionId=`. +- **CLI** — `scadalink audit query --execution-id `. +- **ManagementService** — `/api/audit/query` and the export endpoint accept an + `executionId` filter parameter. + +## Compatibility + +- Two additive nullable columns; additive proto field; additive message-contract + fields — all version-compatible. No data backfill; historical rows keep + `ExecutionId = NULL`. +- `CorrelationId` semantics unchanged — every existing drill-in keeps working. + +## Testing + +- Repository: query-by-`ExecutionId`; migration smoke test. +- Emitter unit tests: each emitter stamps `ExecutionId`; the cached-call lifecycle + rows from one run share it; `NotifyDeliver` echoes `Notifications.OriginExecutionId`. +- Integration: a script run that does a sync call + a cached call + a notification + → all resulting audit rows share one `ExecutionId` end-to-end. +- Central UI: bUnit (grid column, filter, drill-in) + Playwright. + +## Out of scope + +- Bridging the inbound request id into the routed site script's execution + (cross-cluster threading) — a separate future change. +- Backfilling `ExecutionId` on historical audit rows. From 4002f4197bad1b4d0b7676923ca26bee27e06c7a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 14:37:12 -0400 Subject: [PATCH 02/15] docs(plan): Audit Log ExecutionId implementation plan --- docs/plans/2026-05-21-audit-executionid.md | 155 ++++++++++++++++++ ...2026-05-21-audit-executionid.md.tasks.json | 16 ++ 2 files changed, 171 insertions(+) create mode 100644 docs/plans/2026-05-21-audit-executionid.md create mode 100644 docs/plans/2026-05-21-audit-executionid.md.tasks.json diff --git a/docs/plans/2026-05-21-audit-executionid.md b/docs/plans/2026-05-21-audit-executionid.md new file mode 100644 index 0000000..5df3be1 --- /dev/null +++ b/docs/plans/2026-05-21-audit-executionid.md @@ -0,0 +1,155 @@ +# Audit Log ExecutionId — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to execute this plan task-by-task (fresh implementer per task + spec review + code-quality review). + +**Goal:** Add a dedicated `ExecutionId` column to the Audit Log — one universal correlation value, stamped on every audit row, identifying the originating script execution or inbound request. + +**Architecture:** Additive nullable `ExecutionId` (`Guid`) on the audit row (Commons `AuditEvent`, central MS SQL `AuditLog`, site SQLite `auditlog.db`, gRPC `AuditEventDto`). Every emitter stamps it; the `ScriptRuntimeContext` per-execution id is the source for site script runs, threaded through the S&F buffer for retry-loop cached rows and through `NotificationSubmit` → `Notifications.OriginExecutionId` for central `NotifyDeliver` rows. `CorrelationId` is left as the per-operation lifecycle id (and reverts to `null` for sync one-shot calls). Validated design: `docs/plans/2026-05-21-audit-executionid-design.md`. + +**Tech Stack:** .NET 10, EF Core 10 (MS SQL + SQLite), Akka.NET, gRPC, Blazor Server + Bootstrap, System.CommandLine, xUnit + Akka.TestKit.Xunit2 + bUnit + NSubstitute/Moq, Playwright. + +**Ground rules (every task):** branch is `feature/audit-executionid` (already created) — never commit to `main`. Edit in place; never touch `infra/*`; `docker/*` only if a task says so (none do). Stage with explicit `git add ` — never `git add .` / `commit -am`. TDD; full solution stays green (`dotnet build ScadaLink.slnx` 0 warnings — `TreatWarningsAsErrors` is on). Additive contract evolution. Do not push. + +--- + +## Task 0: Prep — verify branch + baseline + +**Files:** none. + +**Steps:** confirm `git branch --show-current` is `feature/audit-executionid`; `dotnet build ScadaLink.slnx` succeeds. + +**Acceptance:** on the branch, solution builds clean. + +--- + +## Task 1: Foundation — `AuditEvent.ExecutionId`, central `AuditLog` column, repository query + +**Files:** +- Modify: `src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs` — add `Guid? ExecutionId`. +- Modify: `src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs` — add `Guid? ExecutionId` filter dimension (single-value, like `CorrelationId`). +- Modify: `src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs` — map the column; add index `IX_AuditLog_Execution (ExecutionId)`. +- Create: a new EF migration under `src/ScadaLink.ConfigurationDatabase/Migrations/` — `AddAuditLogExecutionId` — `ExecutionId uniqueidentifier NULL` + the index. Additive nullable column (metadata-only ALTER, safe on the monthly-partitioned table). Mirror the existing `AddNotificationsTable` migration style. +- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs` — `QueryAsync` translates `filter.ExecutionId` to `e.ExecutionId == value` (mirror the `CorrelationId` clause). Keyset paging untouched. +- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs` — `QueryAsync_FilterByExecutionId`; migration smoke if the suite has that pattern. + +**Approach:** purely additive. `ExecutionId` is `Guid?` everywhere. Generate the migration with `dotnet ef migrations add` against the ConfigurationDatabase project (or hand-write mirroring an existing one — match how the repo does migrations). + +**Commit:** `feat(auditlog): ExecutionId column on AuditEvent + central AuditLog` + +--- + +## Task 2: Foundation — site SQLite + gRPC DTO + +**Files:** +- Modify: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs` — add `ExecutionId TEXT NULL` to the `auditlog.db` `AuditLog` table DDL; the insert command binds it; `MapRow` reads it back. (Site SQLite is created fresh by the writer — an additive column in the `CREATE TABLE` is enough; if the writer has any migration/ALTER path, extend it.) +- Modify: `src/ScadaLink.Communication/Protos/sitestream.proto` — add `string execution_id` to `AuditEventDto` (next free field number; additive). Rebuild regenerates the C# stubs. +- Modify: `src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs` — `ToDto`/`FromDto` map `ExecutionId` ↔ `execution_id` (Guid ↔ string; empty string ↔ null, mirroring the existing `CorrelationId` handling). +- Test: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs` (column present + round-trips); `tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs` (ExecutionId round-trip incl. null). + +**Commit:** `feat(auditlog): ExecutionId on site SQLite schema + gRPC AuditEventDto` + +--- + +## Task 3: Site script-side emitters stamp `ExecutionId` + +**What:** Every audit row a `ScriptRuntimeContext` emits gets `ExecutionId` = the context's per-execution id. Revert the interim "execution id in `CorrelationId` for sync rows" change so `CorrelationId` is purely per-operation again. + +**Files:** +- Modify: `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs`: + - Rename the field `_auditCorrelationId` → `_executionId` (and the ctor param `auditCorrelationId` → `executionId`) for clarity; update XML docs. Thread it to the helpers as today. + - Sync `ApiCall` (`BuildCallAuditEvent`): set `ExecutionId = _executionId`; set `CorrelationId = null` (revert — sync one-shot calls have no operation lifecycle). + - Cached script-side rows (`CachedSubmit`, immediate `ApiCallCached`/`CachedResolve`): set `ExecutionId = _executionId`; `CorrelationId` stays `trackedId.Value`. + - `NotifySend` (`Notify.Send` emission): set `ExecutionId = _executionId`; `CorrelationId` stays the `NotificationId`. +- Modify: `src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs` + `AuditingDbCommand.cs` — thread `_executionId` (rename from the audit-correlation param); sync `DbWrite` event sets `ExecutionId = _executionId` and `CorrelationId = null`. Cached DB write rows: `ExecutionId` set, `CorrelationId` stays `trackedId`. +- Test: extend `tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs`, `DatabaseSyncEmissionTests.cs`, `ExternalSystemCachedCallEmissionTests.cs`, `DatabaseCachedWriteEmissionTests.cs`, `NotifySendAuditEmissionTests.cs`, and `ExecutionCorrelationContextTests.cs` — assert `ExecutionId` is the context's id on every row; assert sync rows now have `CorrelationId == null`; assert cached/notification rows keep their `CorrelationId`. + +**Commit:** `feat(auditlog): site script-side emitters stamp ExecutionId` + +--- + +## Task 4: Cached S&F retry-loop rows carry `ExecutionId` + +**What:** Thread the execution id through the store-and-forward buffer so the retry-loop cached audit rows (`CachedCallLifecycleBridge`) carry `ExecutionId`. This same threading fixes the pre-existing `SourceScript = null` gap on those rows (identical boundary). + +**Files:** +- Modify: the S&F buffered cached-call message / `StoreAndForwardMessage` (or the cached-call payload) in `src/ScadaLink.StoreAndForward/` — carry the originating execution id (and source script) alongside the call. +- Modify: `CachedCallAttemptContext` (find it — `src/ScadaLink.AuditLog/Site/Telemetry/` or StoreAndForward) — add an `ExecutionId` (and `SourceScript`) field. +- Modify: `src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs` `BuildPacket` — set `ExecutionId` from the context (and `SourceScript`, replacing the `SourceScript = null` line). +- Modify the enqueue path (`ExternalSystem.CachedCall` / `Database.CachedWrite` in `ScriptRuntimeContext`) so the execution id is written into the buffered message. +- Test: `tests/ScadaLink.AuditLog.Tests/` cached-telemetry tests + `tests/ScadaLink.StoreAndForward.Tests/` — retry-loop rows carry the originating `ExecutionId`. + +**Note for implementer:** this is the deepest task — the threading touches StoreAndForward. If the buffered message can't cleanly carry the id, STOP and report before guessing. + +**Commit:** `feat(auditlog): thread ExecutionId through S&F for retry-loop cached rows` + +--- + +## Task 5: Central `NotifyDeliver` rows carry `ExecutionId` + +**Files:** +- Modify: `src/ScadaLink.Commons/Entities/Notifications/Notification.cs` — add `Guid? OriginExecutionId`. +- Modify: `src/ScadaLink.Commons/Messages/Notification/` — `NotificationSubmit` carries `Guid? OriginExecutionId` (additive). +- Modify: `src/ScadaLink.ConfigurationDatabase/` — EF config + a new migration `AddNotificationOriginExecutionId` (`Notifications.OriginExecutionId uniqueidentifier NULL`). +- Modify: the site `NotifySend` forward path — the execution id (already on the `NotifySend` audit row from Task 3) also rides on the `NotificationSubmit` (set it where the submit is built — `ScriptRuntimeContext` `Notify.Send` / the S&F notification forwarder). +- Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs` — persist `OriginExecutionId` on insert; `BuildNotifyDeliverEvent` sets `ExecutionId = notification.OriginExecutionId`. +- Test: `tests/ScadaLink.NotificationOutbox.Tests/` — `NotifyDeliver` rows echo `OriginExecutionId`; `tests/ScadaLink.Commons.Tests/` contract shape. + +**Commit:** `feat(auditlog): NotifyDeliver rows carry the originating ExecutionId` + +--- + +## Task 6: Inbound rows carry `ExecutionId` + +**Files:** +- Modify: `src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs` — `EmitInboundAudit` sets `ExecutionId` to the request id (it already mints a `Guid.NewGuid()` for the inbound `CorrelationId` per the 2026-05-21 change; reuse that one id for `ExecutionId` — and reconsider whether the inbound row's `CorrelationId` should now be `null` to keep `CorrelationId` purely per-operation; align with the Task 3 decision: inbound is a one-shot from the audit row's perspective → `CorrelationId = null`, `ExecutionId = `). +- Test: `tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs` — inbound row carries a non-null `ExecutionId`; distinct per request. + +**Commit:** `feat(auditlog): inbound audit rows carry ExecutionId` + +--- + +## Task 7: Central UI — ExecutionId column, filter, drill-in + +**Files:** +- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor` (+ `.razor.cs`) — add `ExecutionId` to the column set (the grid already supports resize/reorder + a `ColumnOrder`); render it (short form / monospace). +- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor` (+ `.razor.cs`) + `AuditQueryModel.cs` — an `ExecutionId` paste text-filter; `ToFilter` maps it to `AuditLogQueryFilter.ExecutionId`. +- Modify: `src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs` — `ApplyQueryStringFilters` accepts `?executionId=`; `BuildExportUrl` emits it. +- Add a "View this execution" drill-in — a row/drilldown action linking `/audit/log?executionId=`. Mirror the existing `?correlationId=` drill-in. +- Test: `tests/ScadaLink.CentralUI.Tests/` bUnit (column renders, filter maps, query-param parsed); `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/` (drill-in filters the grid). + +Use the `frontend-design` skill for the column/filter styling. + +**Commit:** `feat(centralui): ExecutionId column, filter and drill-in on the Audit Log page` + +--- + +## Task 8: CLI + ManagementService — ExecutionId filter + +**Files:** +- Modify: `src/ScadaLink.CLI/Commands/AuditCommands.cs` + `AuditQueryHelpers.cs` — `audit query --execution-id `; `AuditQueryArgs` + `BuildQueryString` emit `executionId`. +- Modify: `src/ScadaLink.ManagementService/AuditEndpoints.cs` `ParseFilter` — parse `executionId` query param into `AuditLogQueryFilter.ExecutionId` (lax-parse — unparseable dropped). +- Modify: `src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs` `ParseFilter` — same. +- Test: `tests/ScadaLink.CLI.Tests/`, `tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs`. + +**Commit:** `feat(audit): ExecutionId filter in the CLI and ManagementService` + +--- + +## Task 9: End-to-end integration test + docs + +**Files:** +- Create: `tests/ScadaLink.IntegrationTests/AuditLog/ExecutionIdCorrelationTests.cs` — boot a site+central pair; run a script that does a sync `ExternalSystem.Call`, a cached call, and a `Notify.Send`; assert every resulting audit row (site + central) shares one `ExecutionId`. +- Modify: `docs/requirements/Component-AuditLog.md` — add `ExecutionId` to the schema table and a sentence on its meaning vs `CorrelationId`. (Do NOT modify `alog.md` — it is the locked v1 spec.) +- Modify: `CLAUDE.md` — one line under the Centralized Audit Log decisions noting `ExecutionId` as the universal per-run correlation value. + +**Commit:** `test(auditlog): end-to-end ExecutionId correlation + docs` + +--- + +## Final review + +Dispatch a final cross-cutting review of the whole branch; full `dotnet build` + `dotnet test ScadaLink.slnx`; hand back to the user for the push/merge/redeploy decision (do not push). + +## Dependency summary + +0 blocks all. 2 blockedBy 1. 3 blockedBy 2. 4 blockedBy 3. 5 blockedBy 2. 6 blockedBy 2. 7 blockedBy 1. 8 blockedBy 1. 9 blockedBy 3,4,5,6,7,8. Execution order: 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → final review. diff --git a/docs/plans/2026-05-21-audit-executionid.md.tasks.json b/docs/plans/2026-05-21-audit-executionid.md.tasks.json new file mode 100644 index 0000000..2db0c8e --- /dev/null +++ b/docs/plans/2026-05-21-audit-executionid.md.tasks.json @@ -0,0 +1,16 @@ +{ + "planPath": "docs/plans/2026-05-21-audit-executionid.md", + "tasks": [ + {"id": 50, "subject": "Task 0: Prep — verify branch + baseline", "status": "pending"}, + {"id": 51, "subject": "Task 1: Foundation — AuditEvent.ExecutionId + central AuditLog column + repo query", "status": "pending", "blockedBy": [50]}, + {"id": 52, "subject": "Task 2: Foundation — site SQLite + gRPC DTO", "status": "pending", "blockedBy": [51]}, + {"id": 53, "subject": "Task 3: Site script-side emitters stamp ExecutionId", "status": "pending", "blockedBy": [52]}, + {"id": 54, "subject": "Task 4: Cached S&F retry-loop rows carry ExecutionId", "status": "pending", "blockedBy": [53]}, + {"id": 55, "subject": "Task 5: Central NotifyDeliver rows carry ExecutionId", "status": "pending", "blockedBy": [52]}, + {"id": 56, "subject": "Task 6: Inbound audit rows carry ExecutionId", "status": "pending", "blockedBy": [52]}, + {"id": 57, "subject": "Task 7: Central UI — ExecutionId column, filter, drill-in", "status": "pending", "blockedBy": [51]}, + {"id": 58, "subject": "Task 8: CLI + ManagementService — ExecutionId filter", "status": "pending", "blockedBy": [51]}, + {"id": 59, "subject": "Task 9: End-to-end integration test + docs", "status": "pending", "blockedBy": [53, 54, 55, 56, 57, 58]} + ], + "lastUpdated": "2026-05-21T00:00:00Z" +} From fd120219848dfb2f06733f27ecec2acdbc8eeae7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 14:43:35 -0400 Subject: [PATCH 03/15] feat(auditlog): ExecutionId column on AuditEvent + central AuditLog --- .../Entities/Audit/AuditEvent.cs | 7 + .../Types/Audit/AuditLogQueryFilter.cs | 5 +- .../AuditLogEntityTypeConfiguration.cs | 4 + ...1184044_AddAuditLogExecutionId.Designer.cs | 1626 +++++++++++++++++ .../20260521184044_AddAuditLogExecutionId.cs | 57 + .../ScadaLinkDbContextModelSnapshot.cs | 7 + .../Repositories/AuditLogRepository.cs | 10 +- .../AuditLogEntityTypeConfigurationTests.cs | 13 +- .../Repositories/AuditLogRepositoryTests.cs | 32 +- 9 files changed, 1754 insertions(+), 7 deletions(-) create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260521184044_AddAuditLogExecutionId.Designer.cs create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260521184044_AddAuditLogExecutionId.cs diff --git a/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs b/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs index f5148a3..7bc98af 100644 --- a/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs +++ b/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs @@ -26,6 +26,13 @@ public sealed record AuditEvent /// Correlation id linking related audit rows (e.g. the cached-op lifecycle). public Guid? CorrelationId { get; init; } + /// + /// Id of the originating script execution / inbound request — the universal + /// per-run correlation value, distinct from (which + /// is the per-operation lifecycle id). + /// + public Guid? ExecutionId { get; init; } + /// Site id where the action originated; null for central-direct events. public string? SourceSiteId { get; init; } diff --git a/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs b/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs index 5399f48..dd902a0 100644 --- a/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs +++ b/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs @@ -11,7 +11,9 @@ namespace ScadaLink.Commons.Types.Audit; /// dimension (translated to a SQL IN (…)). Time bounds are half-open in /// the spec sense — is inclusive and is /// inclusive of the upper bound; the repository SQL uses >= / <= -/// respectively. All filter dimensions are AND-combined with one another. +/// respectively. All filter dimensions are AND-combined with one another. The +/// single-value and +/// dimensions constrain on equality when set. /// public sealed record AuditLogQueryFilter( IReadOnlyList? Channels = null, @@ -21,5 +23,6 @@ public sealed record AuditLogQueryFilter( string? Target = null, string? Actor = null, Guid? CorrelationId = null, + Guid? ExecutionId = null, DateTime? FromUtc = null, DateTime? ToUtc = null); diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs index 9ad90e0..561ec11 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs @@ -89,6 +89,10 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration e.ExecutionId) + .HasFilter("[ExecutionId] IS NOT NULL") + .HasDatabaseName("IX_AuditLog_Execution"); + builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc }) .IsDescending(false, false, true) .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260521184044_AddAuditLogExecutionId.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521184044_AddAuditLogExecutionId.Designer.cs new file mode 100644 index 0000000..2fa073f --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521184044_AddAuditLogExecutionId.Designer.cs @@ -0,0 +1,1626 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ScadaLink.ConfigurationDatabase; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaLinkDbContext))] + [Migration("20260521184044_AddAuditLogExecutionId")] + partial class AddAuditLogExecutionId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditEvent", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Actor") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMs") + .HasColumnType("int"); + + b.Property("ErrorDetail") + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorMessage") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Extra") + .HasColumnType("nvarchar(max)"); + + b.Property("ForwardState") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("PayloadTruncated") + .HasColumnType("bit"); + + b.Property("RequestSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceInstanceId") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceScript") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceSiteId") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_AuditLog_CorrelationId") + .HasFilter("[CorrelationId] IS NOT NULL"); + + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_AuditLog_EventId"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_AuditLog_Execution") + .HasFilter("[ExecutionId] IS NOT NULL"); + + b.HasIndex("OccurredAtUtc") + .IsDescending() + .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); + + b.HasIndex("SourceSiteId", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + b.HasIndex("Target", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Target_Occurred") + .HasFilter("[Target] IS NOT NULL"); + + b.HasIndex("Channel", "Status", "OccurredAtUtc") + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + b.ToTable("AuditLog", (string)null); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.SiteCall", b => + { + b.Property("TrackedOperationId") + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SourceSite") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("TerminalAtUtc") + .HasColumnType("datetime2"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("TrackedOperationId"); + + b.HasIndex("SourceSite", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Source_Created"); + + b.HasIndex("Status", "UpdatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Status_Updated"); + + b.ToTable("SiteCalls", (string)null); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedApiKeyIds") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.Notification", b => + { + b.Property("NotificationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ListName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NextAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("ResolvedTargets") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SiteEnqueuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceInstanceId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceScript") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceSiteId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TypeData") + .HasColumnType("nvarchar(max)"); + + b.HasKey("NotificationId"); + + b.HasIndex("SourceSiteId", "CreatedAt"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Admin" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Design" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployment" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployment" + }); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ScadaLink.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ScadaLink.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260521184044_AddAuditLogExecutionId.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521184044_AddAuditLogExecutionId.cs new file mode 100644 index 0000000..1d61fa1 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521184044_AddAuditLogExecutionId.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + /// + /// Adds the universal ExecutionId correlation column to the centralized + /// AuditLog table (#23). ExecutionId identifies the originating + /// script execution / inbound request and is distinct from the per-operation + /// CorrelationId. + /// + /// The change is purely additive: + /// 1. ExecutionId uniqueidentifier NULL is added with no default, so the + /// operation is a metadata-only ALTER TABLE … ADD — it does NOT + /// rewrite the monthly-partitioned AuditLog table, and historical + /// rows stay NULL (no backfill). + /// 2. IX_AuditLog_Execution is created via raw SQL so it lands on the + /// ps_AuditLog_Month(OccurredAtUtc) partition scheme, matching every + /// other IX_AuditLog_* index. Keeping it partition-aligned preserves + /// the partition-switch purge path (see AuditLogRepository.SwitchOutPartitionAsync). + /// + public partial class AddAuditLogExecutionId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ExecutionId", + table: "AuditLog", + type: "uniqueidentifier", + nullable: true); + + // Raw SQL so the index is created on the partition scheme — EF's + // CreateIndex cannot express the ON ps_AuditLog_Month(OccurredAtUtc) + // clause. Mirrors IX_AuditLog_CorrelationId (filtered, aligned). + migrationBuilder.Sql(@" +CREATE NONCLUSTERED INDEX IX_AuditLog_Execution +ON dbo.AuditLog (ExecutionId) +WHERE ExecutionId IS NOT NULL +ON ps_AuditLog_Month(OccurredAtUtc);"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" +IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Execution' AND object_id = OBJECT_ID('dbo.AuditLog')) + DROP INDEX IX_AuditLog_Execution ON dbo.AuditLog;"); + + migrationBuilder.DropColumn( + name: "ExecutionId", + table: "AuditLog"); + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs index 328a8a5..f69c4a4 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs @@ -73,6 +73,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations .HasMaxLength(1024) .HasColumnType("nvarchar(1024)"); + b.Property("ExecutionId") + .HasColumnType("uniqueidentifier"); + b.Property("Extra") .HasColumnType("nvarchar(max)"); @@ -138,6 +141,10 @@ namespace ScadaLink.ConfigurationDatabase.Migrations .IsUnique() .HasDatabaseName("UX_AuditLog_EventId"); + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_AuditLog_Execution") + .HasFilter("[ExecutionId] IS NOT NULL"); + b.HasIndex("OccurredAtUtc") .IsDescending() .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs index 4a6ce78..1e557a1 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -64,12 +64,12 @@ public class AuditLogRepository : IAuditLogRepository 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, + (EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId, SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState) VALUES - ({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, + ({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, {evt.ExecutionId}, {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});", @@ -157,6 +157,11 @@ VALUES query = query.Where(e => e.CorrelationId == correlationId); } + if (filter.ExecutionId is { } executionId) + { + query = query.Where(e => e.ExecutionId == executionId); + } + if (filter.FromUtc is { } fromUtc) { query = query.Where(e => e.OccurredAtUtc >= fromUtc); @@ -263,6 +268,7 @@ VALUES PayloadTruncated bit NOT NULL, Extra nvarchar(max) NULL, ForwardState varchar(32) NULL, + ExecutionId uniqueidentifier NULL, CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc) ) ON [PRIMARY]; diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs index 9427442..6d9b52e 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs @@ -74,8 +74,9 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable .Where(p => !p.IsShadowProperty()) .ToList(); - // AuditEvent record exposes 21 init-only properties (alog.md §4). - Assert.Equal(21, properties.Count); + // AuditEvent record exposes 22 init-only properties (alog.md §4 plus the + // additive ExecutionId universal correlation column). + Assert.Equal(22, properties.Count); } [Fact] @@ -90,11 +91,13 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable .ToList(); // Five reconciliation/query indexes from alog.md §4, plus the EventId unique - // index introduced alongside the composite PK (Bundle C). + // index introduced alongside the composite PK (Bundle C), plus the additive + // IX_AuditLog_Execution index supporting ExecutionId lookups. var expected = new[] { "IX_AuditLog_Channel_Status_Occurred", "IX_AuditLog_CorrelationId", + "IX_AuditLog_Execution", "IX_AuditLog_OccurredAtUtc", "IX_AuditLog_Site_Occurred", "IX_AuditLog_Target_Occurred", @@ -136,5 +139,9 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable var targetIdx = entity.GetIndexes() .Single(i => i.GetDatabaseName() == "IX_AuditLog_Target_Occurred"); Assert.Equal("[Target] IS NOT NULL", targetIdx.GetFilter()); + + var executionIdx = entity.GetIndexes() + .Single(i => i.GetDatabaseName() == "IX_AuditLog_Execution"); + Assert.Equal("[ExecutionId] IS NOT NULL", executionIdx.GetFilter()); } } diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs index 0ee14f0..646c7df 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs @@ -247,6 +247,34 @@ public class AuditLogRepositoryTests : IClassFixture Assert.All(rows, r => Assert.Equal(siteId, r.SourceSiteId)); } + [SkippableFact] + public async Task QueryAsync_FilterByExecutionId_ReturnsMatchingRows() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + var executionId = Guid.NewGuid(); + var t0 = new DateTime(2026, 5, 3, 12, 0, 0, DateTimeKind.Utc); + // Two rows share the ExecutionId; one carries a different ExecutionId and + // one leaves it null — both must be excluded by the single-value filter. + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: executionId)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: executionId)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), executionId: Guid.NewGuid())); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), executionId: null)); + + var rows = await repo.QueryAsync( + new AuditLogQueryFilter( + SourceSiteIds: new[] { siteId }, + ExecutionId: executionId), + new AuditLogPaging(PageSize: 10)); + + Assert.Equal(2, rows.Count); + Assert.All(rows, r => Assert.Equal(executionId, r.ExecutionId)); + } + [SkippableFact] public async Task QueryAsync_FilterByTimeRange() { @@ -725,7 +753,8 @@ public class AuditLogRepositoryTests : IClassFixture AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, AuditStatus status = AuditStatus.Delivered, - string? errorMessage = null) => + string? errorMessage = null, + Guid? executionId = null) => new() { EventId = Guid.NewGuid(), @@ -735,5 +764,6 @@ public class AuditLogRepositoryTests : IClassFixture Status = status, SourceSiteId = siteId, ErrorMessage = errorMessage, + ExecutionId = executionId, }; } From 990731d12f43b64537e7fe280a4af80580aae624 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 14:48:39 -0400 Subject: [PATCH 04/15] test(auditlog): cover ExecutionId in AuditEvent round-trip test; clarify staging-table comment --- .../Repositories/AuditLogRepository.cs | 3 +++ .../Entities/Audit/AuditEventTests.cs | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs index 1e557a1..25d44c0 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -268,6 +268,9 @@ VALUES PayloadTruncated bit NOT NULL, Extra nvarchar(max) NULL, ForwardState varchar(32) NULL, + -- ExecutionId is last because it was added to the live AuditLog table by a later + -- ALTER TABLE ADD migration; the staging table must match the live table column + -- shape ordinal-for-ordinal or ALTER TABLE ... SWITCH PARTITION fails. ExecutionId uniqueidentifier NULL, CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc) ) ON [PRIMARY]; diff --git a/tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs b/tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs index 37ccb9f..89a68fd 100644 --- a/tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs +++ b/tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs @@ -17,6 +17,7 @@ public class AuditEventTests var occurredAt = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc); var ingestedAt = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc); var corrId = Guid.NewGuid(); + var execId = Guid.NewGuid(); var evt = new AuditEvent { @@ -26,6 +27,7 @@ public class AuditEventTests Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, CorrelationId = corrId, + ExecutionId = execId, SourceSiteId = "site-01", SourceInstanceId = "inst-7", SourceScript = "OnAlarm", @@ -49,6 +51,7 @@ public class AuditEventTests Assert.Equal(AuditChannel.ApiOutbound, evt.Channel); Assert.Equal(AuditKind.ApiCall, evt.Kind); Assert.Equal(corrId, evt.CorrelationId); + Assert.Equal(execId, evt.ExecutionId); Assert.Equal("site-01", evt.SourceSiteId); Assert.Equal("inst-7", evt.SourceInstanceId); Assert.Equal("OnAlarm", evt.SourceScript); @@ -77,6 +80,7 @@ public class AuditEventTests Channel = AuditChannel.Notification, Kind = AuditKind.NotifySend, CorrelationId = null, + ExecutionId = null, SourceSiteId = null, SourceInstanceId = null, SourceScript = null, @@ -96,6 +100,7 @@ public class AuditEventTests Assert.Null(evt.IngestedAtUtc); Assert.Null(evt.CorrelationId); + Assert.Null(evt.ExecutionId); Assert.Null(evt.SourceSiteId); Assert.Null(evt.SourceInstanceId); Assert.Null(evt.SourceScript); From 6b16a4888604373388b18d63d6850a9ac24d389a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 14:53:08 -0400 Subject: [PATCH 05/15] feat(auditlog): ExecutionId on site SQLite schema + gRPC AuditEventDto --- .../Site/SqliteAuditWriter.cs | 19 ++- .../Grpc/AuditEventDtoMapper.cs | 2 + .../Protos/sitestream.proto | 1 + .../SiteStreamGrpc/Sitestream.cs | 118 ++++++++++++------ .../Site/SqliteAuditWriterSchemaTests.cs | 8 +- .../Site/SqliteAuditWriterWriteTests.cs | 33 +++++ .../AuditEventDtoMapperTests.cs | 6 + 7 files changed, 139 insertions(+), 48 deletions(-) diff --git a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs index 3bce65c..cf2b216 100644 --- a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs +++ b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs @@ -114,6 +114,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable PayloadTruncated INTEGER NOT NULL, Extra TEXT NULL, ForwardState TEXT NOT NULL, + ExecutionId TEXT NULL, PRIMARY KEY (EventId) ); CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred @@ -221,12 +222,14 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable EventId, OccurredAtUtc, Channel, Kind, CorrelationId, SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, - RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState + RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, + ExecutionId ) VALUES ( $EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId, $SourceSiteId, $SourceInstanceId, $SourceScript, $Actor, $Target, $Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail, - $RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState + $RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState, + $ExecutionId ); """; @@ -250,6 +253,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable var pPayloadTruncated = cmd.Parameters.Add("$PayloadTruncated", SqliteType.Integer); var pExtra = cmd.Parameters.Add("$Extra", SqliteType.Text); var pForwardState = cmd.Parameters.Add("$ForwardState", SqliteType.Text); + var pExecutionId = cmd.Parameters.Add("$ExecutionId", SqliteType.Text); foreach (var pending in batch) { @@ -274,6 +278,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable pPayloadTruncated.Value = e.PayloadTruncated ? 1 : 0; pExtra.Value = (object?)e.Extra ?? DBNull.Value; pForwardState.Value = (e.ForwardState ?? AuditForwardState.Pending).ToString(); + pExecutionId.Value = (object?)e.ExecutionId?.ToString() ?? DBNull.Value; try { @@ -331,7 +336,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, - RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState + RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, + ExecutionId FROM AuditLog WHERE ForwardState = $pending ORDER BY OccurredAtUtc ASC, EventId ASC @@ -379,7 +385,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, - RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState + RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, + ExecutionId FROM AuditLog WHERE ForwardState = $forwarded ORDER BY OccurredAtUtc ASC, EventId ASC @@ -465,7 +472,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, - RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState + RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, + ExecutionId FROM AuditLog WHERE ForwardState IN ($pending, $forwarded) AND OccurredAtUtc >= $since @@ -642,6 +650,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable PayloadTruncated = reader.GetInt32(17) != 0, Extra = reader.IsDBNull(18) ? null : reader.GetString(18), ForwardState = Enum.Parse(reader.GetString(19)), + ExecutionId = reader.IsDBNull(20) ? null : Guid.Parse(reader.GetString(20)), }; } diff --git a/src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs b/src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs index ed5fb22..4a679e7 100644 --- a/src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs +++ b/src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs @@ -47,6 +47,7 @@ public static class AuditEventDtoMapper Channel = evt.Channel.ToString(), Kind = evt.Kind.ToString(), CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty, + ExecutionId = evt.ExecutionId?.ToString() ?? string.Empty, SourceSiteId = evt.SourceSiteId ?? string.Empty, SourceInstanceId = evt.SourceInstanceId ?? string.Empty, SourceScript = evt.SourceScript ?? string.Empty, @@ -92,6 +93,7 @@ public static class AuditEventDtoMapper Channel = Enum.Parse(dto.Channel), Kind = Enum.Parse(dto.Kind), CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null, + ExecutionId = NullIfEmpty(dto.ExecutionId) is { } eid ? Guid.Parse(eid) : null, SourceSiteId = NullIfEmpty(dto.SourceSiteId), SourceInstanceId = NullIfEmpty(dto.SourceInstanceId), SourceScript = NullIfEmpty(dto.SourceScript), diff --git a/src/ScadaLink.Communication/Protos/sitestream.proto b/src/ScadaLink.Communication/Protos/sitestream.proto index 5ceb709..9c671e9 100644 --- a/src/ScadaLink.Communication/Protos/sitestream.proto +++ b/src/ScadaLink.Communication/Protos/sitestream.proto @@ -91,6 +91,7 @@ message AuditEventDto { string response_summary = 17; bool payload_truncated = 18; string extra = 19; + string execution_id = 20; // empty string represents null } message AuditEventBatch { repeated AuditEventDto events = 1; } diff --git a/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs b/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs index ccac2bb..d591e78 100644 --- a/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs +++ b/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs @@ -41,7 +41,7 @@ namespace ScadaLink.Communication.Grpc { "c3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVtEhAKCHBy", "aW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90", "b2J1Zi5UaW1lc3RhbXASKQoFbGV2ZWwYBiABKA4yGi5zaXRlc3RyZWFtLkFs", - "YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAki9QMKDUF1ZGl0RXZlbnRE", + "YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAkiiwQKDUF1ZGl0RXZlbnRE", "dG8SEAoIZXZlbnRfaWQYASABKAkSMwoPb2NjdXJyZWRfYXRfdXRjGAIgASgL", "MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjaGFubmVsGAMgASgJ", "EgwKBGtpbmQYBCABKAkSFgoOY29ycmVsYXRpb25faWQYBSABKAkSFgoOc291", @@ -52,43 +52,43 @@ namespace ScadaLink.Communication.Grpc { "GA0gASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUSFQoNZXJyb3Jf", "bWVzc2FnZRgOIAEoCRIUCgxlcnJvcl9kZXRhaWwYDyABKAkSFwoPcmVxdWVz", "dF9zdW1tYXJ5GBAgASgJEhgKEHJlc3BvbnNlX3N1bW1hcnkYESABKAkSGQoR", - "cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkiPAoPQXVk", - "aXRFdmVudEJhdGNoEikKBmV2ZW50cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVk", - "aXRFdmVudER0byInCglJbmdlc3RBY2sSGgoSYWNjZXB0ZWRfZXZlbnRfaWRz", - "GAEgAygJIvQCChZTaXRlQ2FsbE9wZXJhdGlvbmFsRHRvEhwKFHRyYWNrZWRf", - "b3BlcmF0aW9uX2lkGAEgASgJEg8KB2NoYW5uZWwYAiABKAkSDgoGdGFyZ2V0", - "GAMgASgJEhMKC3NvdXJjZV9zaXRlGAQgASgJEg4KBnN0YXR1cxgFIAEoCRIT", - "CgtyZXRyeV9jb3VudBgGIAEoBRISCgpsYXN0X2Vycm9yGAcgASgJEjAKC2h0", - "dHBfc3RhdHVzGAggASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUS", - "MgoOY3JlYXRlZF9hdF91dGMYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGlt", - "ZXN0YW1wEjIKDnVwZGF0ZWRfYXRfdXRjGAogASgLMhouZ29vZ2xlLnByb3Rv", - "YnVmLlRpbWVzdGFtcBIzCg90ZXJtaW5hbF9hdF91dGMYCyABKAsyGi5nb29n", - "bGUucHJvdG9idWYuVGltZXN0YW1wIoABChVDYWNoZWRUZWxlbWV0cnlQYWNr", - "ZXQSLgoLYXVkaXRfZXZlbnQYASABKAsyGS5zaXRlc3RyZWFtLkF1ZGl0RXZl", - "bnREdG8SNwoLb3BlcmF0aW9uYWwYAiABKAsyIi5zaXRlc3RyZWFtLlNpdGVD", - "YWxsT3BlcmF0aW9uYWxEdG8iSgoUQ2FjaGVkVGVsZW1ldHJ5QmF0Y2gSMgoH", - "cGFja2V0cxgBIAMoCzIhLnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1ldHJ5UGFj", - "a2V0IlsKFlB1bGxBdWRpdEV2ZW50c1JlcXVlc3QSLQoJc2luY2VfdXRjGAEg", - "ASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpiYXRjaF9zaXpl", - "GAIgASgFIlwKF1B1bGxBdWRpdEV2ZW50c1Jlc3BvbnNlEikKBmV2ZW50cxgB", - "IAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0bxIWCg5tb3JlX2F2YWls", - "YWJsZRgCIAEoCCpcCgdRdWFsaXR5EhcKE1FVQUxJVFlfVU5TUEVDSUZJRUQQ", - "ABIQCgxRVUFMSVRZX0dPT0QQARIVChFRVUFMSVRZX1VOQ0VSVEFJThACEg8K", - "C1FVQUxJVFlfQkFEEAMqXQoOQWxhcm1TdGF0ZUVudW0SGwoXQUxBUk1fU1RB", - "VEVfVU5TUEVDSUZJRUQQABIWChJBTEFSTV9TVEFURV9OT1JNQUwQARIWChJB", - "TEFSTV9TVEFURV9BQ1RJVkUQAiqFAQoOQWxhcm1MZXZlbEVudW0SFAoQQUxB", - "Uk1fTEVWRUxfTk9ORRAAEhMKD0FMQVJNX0xFVkVMX0xPVxABEhcKE0FMQVJN", - "X0xFVkVMX0xPV19MT1cQAhIUChBBTEFSTV9MRVZFTF9ISUdIEAMSGQoVQUxB", - "Uk1fTEVWRUxfSElHSF9ISUdIEAQy4QIKEVNpdGVTdHJlYW1TZXJ2aWNlElUK", - "EVN1YnNjcmliZUluc3RhbmNlEiEuc2l0ZXN0cmVhbS5JbnN0YW5jZVN0cmVh", - "bVJlcXVlc3QaGy5zaXRlc3RyZWFtLlNpdGVTdHJlYW1FdmVudDABEkcKEUlu", - "Z2VzdEF1ZGl0RXZlbnRzEhsuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50QmF0Y2ga", - "FS5zaXRlc3RyZWFtLkluZ2VzdEFjaxJQChVJbmdlc3RDYWNoZWRUZWxlbWV0", - "cnkSIC5zaXRlc3RyZWFtLkNhY2hlZFRlbGVtZXRyeUJhdGNoGhUuc2l0ZXN0", - "cmVhbS5Jbmdlc3RBY2sSWgoPUHVsbEF1ZGl0RXZlbnRzEiIuc2l0ZXN0cmVh", - "bS5QdWxsQXVkaXRFdmVudHNSZXF1ZXN0GiMuc2l0ZXN0cmVhbS5QdWxsQXVk", - "aXRFdmVudHNSZXNwb25zZUIfqgIcU2NhZGFMaW5rLkNvbW11bmljYXRpb24u", - "R3JwY2IGcHJvdG8z")); + "cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkSFAoMZXhl", + "Y3V0aW9uX2lkGBQgASgJIjwKD0F1ZGl0RXZlbnRCYXRjaBIpCgZldmVudHMY", + "ASADKAsyGS5zaXRlc3RyZWFtLkF1ZGl0RXZlbnREdG8iJwoJSW5nZXN0QWNr", + "EhoKEmFjY2VwdGVkX2V2ZW50X2lkcxgBIAMoCSL0AgoWU2l0ZUNhbGxPcGVy", + "YXRpb25hbER0bxIcChR0cmFja2VkX29wZXJhdGlvbl9pZBgBIAEoCRIPCgdj", + "aGFubmVsGAIgASgJEg4KBnRhcmdldBgDIAEoCRITCgtzb3VyY2Vfc2l0ZRgE", + "IAEoCRIOCgZzdGF0dXMYBSABKAkSEwoLcmV0cnlfY291bnQYBiABKAUSEgoK", + "bGFzdF9lcnJvchgHIAEoCRIwCgtodHRwX3N0YXR1cxgIIAEoCzIbLmdvb2ds", + "ZS5wcm90b2J1Zi5JbnQzMlZhbHVlEjIKDmNyZWF0ZWRfYXRfdXRjGAkgASgL", + "MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIyCg51cGRhdGVkX2F0X3V0", + "YxgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASMwoPdGVybWlu", + "YWxfYXRfdXRjGAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKA", + "AQoVQ2FjaGVkVGVsZW1ldHJ5UGFja2V0Ei4KC2F1ZGl0X2V2ZW50GAEgASgL", + "Mhkuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50RHRvEjcKC29wZXJhdGlvbmFsGAIg", + "ASgLMiIuc2l0ZXN0cmVhbS5TaXRlQ2FsbE9wZXJhdGlvbmFsRHRvIkoKFENh", + "Y2hlZFRlbGVtZXRyeUJhdGNoEjIKB3BhY2tldHMYASADKAsyIS5zaXRlc3Ry", + "ZWFtLkNhY2hlZFRlbGVtZXRyeVBhY2tldCJbChZQdWxsQXVkaXRFdmVudHNS", + "ZXF1ZXN0Ei0KCXNpbmNlX3V0YxgBIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5U", + "aW1lc3RhbXASEgoKYmF0Y2hfc2l6ZRgCIAEoBSJcChdQdWxsQXVkaXRFdmVu", + "dHNSZXNwb25zZRIpCgZldmVudHMYASADKAsyGS5zaXRlc3RyZWFtLkF1ZGl0", + "RXZlbnREdG8SFgoObW9yZV9hdmFpbGFibGUYAiABKAgqXAoHUXVhbGl0eRIX", + "ChNRVUFMSVRZX1VOU1BFQ0lGSUVEEAASEAoMUVVBTElUWV9HT09EEAESFQoR", + "UVVBTElUWV9VTkNFUlRBSU4QAhIPCgtRVUFMSVRZX0JBRBADKl0KDkFsYXJt", + "U3RhdGVFbnVtEhsKF0FMQVJNX1NUQVRFX1VOU1BFQ0lGSUVEEAASFgoSQUxB", + "Uk1fU1RBVEVfTk9STUFMEAESFgoSQUxBUk1fU1RBVEVfQUNUSVZFEAIqhQEK", + "DkFsYXJtTGV2ZWxFbnVtEhQKEEFMQVJNX0xFVkVMX05PTkUQABITCg9BTEFS", + "TV9MRVZFTF9MT1cQARIXChNBTEFSTV9MRVZFTF9MT1dfTE9XEAISFAoQQUxB", + "Uk1fTEVWRUxfSElHSBADEhkKFUFMQVJNX0xFVkVMX0hJR0hfSElHSBAEMuEC", + "ChFTaXRlU3RyZWFtU2VydmljZRJVChFTdWJzY3JpYmVJbnN0YW5jZRIhLnNp", + "dGVzdHJlYW0uSW5zdGFuY2VTdHJlYW1SZXF1ZXN0Ghsuc2l0ZXN0cmVhbS5T", + "aXRlU3RyZWFtRXZlbnQwARJHChFJbmdlc3RBdWRpdEV2ZW50cxIbLnNpdGVz", + "dHJlYW0uQXVkaXRFdmVudEJhdGNoGhUuc2l0ZXN0cmVhbS5Jbmdlc3RBY2sS", + "UAoVSW5nZXN0Q2FjaGVkVGVsZW1ldHJ5EiAuc2l0ZXN0cmVhbS5DYWNoZWRU", + "ZWxlbWV0cnlCYXRjaBoVLnNpdGVzdHJlYW0uSW5nZXN0QWNrEloKD1B1bGxB", + "dWRpdEV2ZW50cxIiLnNpdGVzdHJlYW0uUHVsbEF1ZGl0RXZlbnRzUmVxdWVz", + "dBojLnNpdGVzdHJlYW0uUHVsbEF1ZGl0RXZlbnRzUmVzcG9uc2VCH6oCHFNj", + "YWRhTGluay5Db21tdW5pY2F0aW9uLkdycGNiBnByb3RvMw==")); descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, 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[] { @@ -96,7 +96,7 @@ namespace ScadaLink.Communication.Grpc { 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.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.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", "ExecutionId" }, 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), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteCallOperationalDto), global::ScadaLink.Communication.Grpc.SiteCallOperationalDto.Parser, new[]{ "TrackedOperationId", "Channel", "Target", "SourceSite", "Status", "RetryCount", "LastError", "HttpStatus", "CreatedAtUtc", "UpdatedAtUtc", "TerminalAtUtc" }, null, null, null, null), @@ -1591,6 +1591,7 @@ namespace ScadaLink.Communication.Grpc { responseSummary_ = other.responseSummary_; payloadTruncated_ = other.payloadTruncated_; extra_ = other.extra_; + executionId_ = other.executionId_; _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } @@ -1838,6 +1839,21 @@ namespace ScadaLink.Communication.Grpc { } } + /// Field number for the "execution_id" field. + public const int ExecutionIdFieldNumber = 20; + private string executionId_ = ""; + /// + /// empty string represents null + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string ExecutionId { + get { return executionId_; } + set { + executionId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { @@ -1872,6 +1888,7 @@ namespace ScadaLink.Communication.Grpc { if (ResponseSummary != other.ResponseSummary) return false; if (PayloadTruncated != other.PayloadTruncated) return false; if (Extra != other.Extra) return false; + if (ExecutionId != other.ExecutionId) return false; return Equals(_unknownFields, other._unknownFields); } @@ -1898,6 +1915,7 @@ namespace ScadaLink.Communication.Grpc { if (ResponseSummary.Length != 0) hash ^= ResponseSummary.GetHashCode(); if (PayloadTruncated != false) hash ^= PayloadTruncated.GetHashCode(); if (Extra.Length != 0) hash ^= Extra.GetHashCode(); + if (ExecutionId.Length != 0) hash ^= ExecutionId.GetHashCode(); if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } @@ -1990,6 +2008,10 @@ namespace ScadaLink.Communication.Grpc { output.WriteRawTag(154, 1); output.WriteString(Extra); } + if (ExecutionId.Length != 0) { + output.WriteRawTag(162, 1); + output.WriteString(ExecutionId); + } if (_unknownFields != null) { _unknownFields.WriteTo(output); } @@ -2074,6 +2096,10 @@ namespace ScadaLink.Communication.Grpc { output.WriteRawTag(154, 1); output.WriteString(Extra); } + if (ExecutionId.Length != 0) { + output.WriteRawTag(162, 1); + output.WriteString(ExecutionId); + } if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } @@ -2141,6 +2167,9 @@ namespace ScadaLink.Communication.Grpc { if (Extra.Length != 0) { size += 2 + pb::CodedOutputStream.ComputeStringSize(Extra); } + if (ExecutionId.Length != 0) { + size += 2 + pb::CodedOutputStream.ComputeStringSize(ExecutionId); + } if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } @@ -2217,6 +2246,9 @@ namespace ScadaLink.Communication.Grpc { if (other.Extra.Length != 0) { Extra = other.Extra; } + if (other.ExecutionId.Length != 0) { + ExecutionId = other.ExecutionId; + } _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } @@ -2321,6 +2353,10 @@ namespace ScadaLink.Communication.Grpc { Extra = input.ReadString(); break; } + case 162: { + ExecutionId = input.ReadString(); + break; + } } } #endif @@ -2425,6 +2461,10 @@ namespace ScadaLink.Communication.Grpc { Extra = input.ReadString(); break; } + case 162: { + ExecutionId = input.ReadString(); + break; + } } } } diff --git a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs index b02dd63..e6015fd 100644 --- a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs @@ -41,9 +41,9 @@ public class SqliteAuditWriterSchemaTests } [Fact] - public void Opens_Creates_AuditLog_Table_With_20Columns_And_PK_On_EventId() + public void Opens_Creates_AuditLog_Table_With_21Columns_And_PK_On_EventId() { - var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_20Columns_And_PK_On_EventId)); + var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_21Columns_And_PK_On_EventId)); using (writer) { using var connection = OpenVerifierConnection(dataSource); @@ -57,7 +57,7 @@ public class SqliteAuditWriterSchemaTests columns.Add((reader.GetString(1), reader.GetInt32(5))); } - Assert.Equal(20, columns.Count); + Assert.Equal(21, columns.Count); var expected = new[] { @@ -65,7 +65,7 @@ public class SqliteAuditWriterSchemaTests "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", - "ForwardState", + "ForwardState", "ExecutionId", }; Assert.Equal(expected.OrderBy(n => n), columns.Select(c => c.Name).OrderBy(n => n)); diff --git a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs index f9fe5c4..58dc32c 100644 --- a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs @@ -353,4 +353,37 @@ public class SqliteAuditWriterWriteTests await writer.MarkReconciledAsync(new[] { Guid.NewGuid(), Guid.NewGuid() }); // Completes without throwing. } + + // ----- ExecutionId column (universal per-run correlation value) ----- // + + [Fact] + public async Task WriteAsync_NonNullExecutionId_RoundTripsThroughMapRow() + { + var (writer, _) = CreateWriter(nameof(WriteAsync_NonNullExecutionId_RoundTripsThroughMapRow)); + await using var _w = writer; + + var executionId = Guid.NewGuid(); + var evt = NewEvent() with { ExecutionId = executionId }; + await writer.WriteAsync(evt); + + var rows = await writer.ReadPendingAsync(limit: 10); + + var row = Assert.Single(rows); + Assert.Equal(executionId, row.ExecutionId); + } + + [Fact] + public async Task WriteAsync_NullExecutionId_RoundTripsAsNull() + { + var (writer, _) = CreateWriter(nameof(WriteAsync_NullExecutionId_RoundTripsAsNull)); + await using var _w = writer; + + var evt = NewEvent() with { ExecutionId = null }; + await writer.WriteAsync(evt); + + var rows = await writer.ReadPendingAsync(limit: 10); + + var row = Assert.Single(rows); + Assert.Null(row.ExecutionId); + } } diff --git a/tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs b/tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs index a247ec6..c741855 100644 --- a/tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs +++ b/tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs @@ -19,6 +19,7 @@ public class AuditEventDtoMapperTests 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 executionId = Guid.NewGuid(); var eventId = Guid.NewGuid(); var original = new AuditEvent @@ -29,6 +30,7 @@ public class AuditEventDtoMapperTests Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCallCached, CorrelationId = correlationId, + ExecutionId = executionId, SourceSiteId = "site-1", SourceInstanceId = "Pump01", SourceScript = "OnDemand", @@ -54,6 +56,7 @@ public class AuditEventDtoMapperTests Assert.Equal(original.Channel, roundTripped.Channel); Assert.Equal(original.Kind, roundTripped.Kind); Assert.Equal(original.CorrelationId, roundTripped.CorrelationId); + Assert.Equal(original.ExecutionId, roundTripped.ExecutionId); Assert.Equal(original.SourceSiteId, roundTripped.SourceSiteId); Assert.Equal(original.SourceInstanceId, roundTripped.SourceInstanceId); Assert.Equal(original.SourceScript, roundTripped.SourceScript); @@ -90,6 +93,7 @@ public class AuditEventDtoMapperTests var dto = AuditEventDtoMapper.ToDto(evt); Assert.Equal(string.Empty, dto.CorrelationId); + Assert.Equal(string.Empty, dto.ExecutionId); Assert.Equal(string.Empty, dto.SourceSiteId); Assert.Equal(string.Empty, dto.SourceInstanceId); Assert.Equal(string.Empty, dto.SourceScript); @@ -113,6 +117,7 @@ public class AuditEventDtoMapperTests Kind = nameof(AuditKind.ApiCall), Status = nameof(AuditStatus.Submitted), CorrelationId = string.Empty, + ExecutionId = string.Empty, SourceSiteId = string.Empty, SourceInstanceId = string.Empty, SourceScript = string.Empty, @@ -128,6 +133,7 @@ public class AuditEventDtoMapperTests var evt = AuditEventDtoMapper.FromDto(dto); Assert.Null(evt.CorrelationId); + Assert.Null(evt.ExecutionId); Assert.Null(evt.SourceSiteId); Assert.Null(evt.SourceInstanceId); Assert.Null(evt.SourceScript); From 0149ce618095667873110881c392dc23f32ac0cc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 15:05:00 -0400 Subject: [PATCH 06/15] feat(auditlog): site script-side emitters stamp ExecutionId Move the per-script-execution Guid on ScriptRuntimeContext from _auditCorrelationId to _executionId, and stamp it into the dedicated AuditEvent.ExecutionId column on every script-side audit row: - Sync ApiCall / DbWrite: ExecutionId set; CorrelationId reverts to null (a sync one-shot call has no operation lifecycle). - Cached-call script-side rows (CachedSubmit, immediate-completion ApiCallCached + CachedResolve) and NotifySend: ExecutionId set; CorrelationId unchanged (per-operation TrackedOperationId / NotificationId). Renames the threaded ctor param/field across ExternalSystemHelper, DatabaseHelper, AuditingDbConnection and AuditingDbCommand, and threads the id through NotifyHelper/NotifyTarget. The S&F retry-loop cached rows (CachedCallLifecycleBridge) are out of scope here. --- .../Scripts/AuditingDbCommand.cs | 18 ++-- .../Scripts/AuditingDbConnection.cs | 10 +- .../Scripts/ScriptRuntimeContext.cs | 93 ++++++++++++++----- .../DatabaseCachedWriteEmissionTests.cs | 16 +++- .../Scripts/DatabaseSyncEmissionTests.cs | 30 +++--- .../ExecutionCorrelationContextTests.cs | 39 ++++---- .../ExternalSystemCachedCallEmissionTests.cs | 21 ++++- .../ExternalSystemCallAuditEmissionTests.cs | 47 ++++++---- .../Scripts/NotifyHelperTests.cs | 3 +- .../Scripts/NotifySendAuditEmissionTests.cs | 11 ++- 10 files changed, 193 insertions(+), 95 deletions(-) diff --git a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs index 3e59ecf..e5427ed 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs @@ -37,11 +37,11 @@ internal sealed class AuditingDbCommand : DbCommand private readonly string _siteId; private readonly string _instanceName; private readonly string? _sourceScript; - private readonly Guid _auditCorrelationId; + private readonly Guid _executionId; private readonly ILogger _logger; private DbConnection? _wrappingConnection; - // Parameter ordering: auditCorrelationId sits immediately after the ILogger, + // Parameter ordering: executionId sits immediately after the ILogger, // consistent with the other three audit-threaded ctors (ExternalSystemHelper, // DatabaseHelper, AuditingDbConnection). public AuditingDbCommand( @@ -52,7 +52,7 @@ internal sealed class AuditingDbCommand : DbCommand string instanceName, string? sourceScript, ILogger logger, - Guid auditCorrelationId) + Guid executionId) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter)); @@ -61,7 +61,7 @@ internal sealed class AuditingDbCommand : DbCommand _instanceName = instanceName ?? string.Empty; _sourceScript = sourceScript; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _auditCorrelationId = auditCorrelationId; + _executionId = executionId; } // -- Forwarded surface ------------------------------------------------ @@ -432,10 +432,12 @@ internal sealed class AuditingDbCommand : DbCommand OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), Channel = AuditChannel.DbOutbound, Kind = AuditKind.DbWrite, - // Audit Log #23: the execution-wide correlation id, so this sync - // DbWrite row shares an id with the other sync trust-boundary rows - // from the same script run. - CorrelationId = _auditCorrelationId, + // Audit Log #23: a sync one-shot DB write has no operation + // lifecycle, so CorrelationId is null. ExecutionId carries the + // per-execution id so this row shares an id with the other sync + // trust-boundary rows from the same script run. + CorrelationId = null, + ExecutionId = _executionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, diff --git a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs index a8d0ee0..c5a52e1 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs @@ -36,10 +36,10 @@ internal sealed class AuditingDbConnection : DbConnection private readonly string _siteId; private readonly string _instanceName; private readonly string? _sourceScript; - private readonly Guid _auditCorrelationId; + private readonly Guid _executionId; private readonly ILogger _logger; - // Parameter ordering: auditCorrelationId sits immediately after the ILogger, + // Parameter ordering: executionId sits immediately after the ILogger, // consistent with the other three audit-threaded ctors (ExternalSystemHelper, // DatabaseHelper, AuditingDbCommand). public AuditingDbConnection( @@ -50,7 +50,7 @@ internal sealed class AuditingDbConnection : DbConnection string instanceName, string? sourceScript, ILogger logger, - Guid auditCorrelationId) + Guid executionId) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter)); @@ -59,7 +59,7 @@ internal sealed class AuditingDbConnection : DbConnection _instanceName = instanceName ?? string.Empty; _sourceScript = sourceScript; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _auditCorrelationId = auditCorrelationId; + _executionId = executionId; } // ConnectionString is settable on DbConnection — forward both halves. @@ -99,7 +99,7 @@ internal sealed class AuditingDbConnection : DbConnection _instanceName, _sourceScript, _logger, - _auditCorrelationId); + _executionId); } protected override void Dispose(bool disposing) diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs index ed643ef..bfdbaa4 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -106,19 +106,22 @@ public class ScriptRuntimeContext private readonly ICachedCallTelemetryForwarder? _cachedForwarder; /// - /// Audit Log #23: the execution-wide audit correlation id. Every sync + /// Audit Log #23: the per-execution id for this script run. Every /// trust-boundary audit row emitted by this script execution - /// (ApiCall, DbWrite) is stamped with this id so all the - /// rows from one script run can be correlated together. + /// (sync ApiCall/DbWrite, cached-call lifecycle rows, + /// NotifySend) is stamped into AuditEvent.ExecutionId with + /// this value so all the rows from one script run can be correlated + /// together — independently of the per-operation + /// AuditEvent.CorrelationId. /// - private readonly Guid _auditCorrelationId; + private readonly Guid _executionId; - /// - /// Audit Log #23: the execution-wide audit correlation id. When omitted + /// + /// Audit Log #23: the per-execution id for this script run. When omitted /// (tag-change / timer-triggered executions) a fresh id is generated; an /// inbound caller may supply one to tie the execution to an upstream - /// request. Stamped on the sync ApiCall/DbWrite audit rows - /// this execution emits. + /// request. Stamped into AuditEvent.ExecutionId on every + /// trust-boundary audit row this execution emits. /// public ScriptRuntimeContext( IActorRef instanceActor, @@ -138,7 +141,7 @@ public class ScriptRuntimeContext IAuditWriter? auditWriter = null, IOperationTrackingStore? operationTrackingStore = null, ICachedCallTelemetryForwarder? cachedForwarder = null, - Guid? auditCorrelationId = null) + Guid? executionId = null) { _instanceActor = instanceActor; _self = self; @@ -157,7 +160,7 @@ public class ScriptRuntimeContext _auditWriter = auditWriter; _operationTrackingStore = operationTrackingStore; _cachedForwarder = cachedForwarder; - _auditCorrelationId = auditCorrelationId ?? Guid.NewGuid(); + _executionId = executionId ?? Guid.NewGuid(); } /// @@ -258,7 +261,7 @@ public class ScriptRuntimeContext /// ExternalSystem.CachedCall("systemName", "methodName", params) /// public ExternalSystemHelper ExternalSystem => new( - _externalSystemClient, _instanceName, _logger, _auditCorrelationId, _auditWriter, _siteId, _sourceScript, + _externalSystemClient, _instanceName, _logger, _executionId, _auditWriter, _siteId, _sourceScript, // Audit Log #23 (M3 Bundle E — Task E3): emit CachedSubmit telemetry // on every ExternalSystem.CachedCall enqueue. _cachedForwarder); @@ -272,7 +275,7 @@ public class ScriptRuntimeContext _databaseGateway, _instanceName, _logger, - _auditCorrelationId, + _executionId, // Audit Log #23 (M4 Bundle A): wire the IAuditWriter so // Database.Connection(name) returns an auditing decorator that // emits one DbOutbound/DbWrite row per script-initiated @@ -299,7 +302,7 @@ public class ScriptRuntimeContext /// public NotifyHelper Notify => new( _storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger, - _auditWriter); + _executionId, _auditWriter); /// /// Audit Log #23 (M3): site-local tracking-status API for cached operations. @@ -380,7 +383,7 @@ public class ScriptRuntimeContext private readonly IExternalSystemClient? _client; private readonly string _instanceName; private readonly ILogger _logger; - private readonly Guid _auditCorrelationId; + private readonly Guid _executionId; private readonly IAuditWriter? _auditWriter; private readonly string _siteId; private readonly string? _sourceScript; @@ -390,7 +393,7 @@ public class ScriptRuntimeContext // (via InternalsVisibleTo). Production sites resolve the helper through // ScriptRuntimeContext.ExternalSystem. // - // Parameter ordering: auditCorrelationId sits immediately after the + // Parameter ordering: executionId sits immediately after the // ILogger across all four audit-threaded ctors (ExternalSystemHelper, // DatabaseHelper, AuditingDbConnection, AuditingDbCommand) — a required // Guid cannot follow the optional provenance params without a @@ -400,7 +403,7 @@ public class ScriptRuntimeContext IExternalSystemClient? client, string instanceName, ILogger logger, - Guid auditCorrelationId, + Guid executionId, IAuditWriter? auditWriter = null, string siteId = "", string? sourceScript = null, @@ -409,7 +412,7 @@ public class ScriptRuntimeContext _client = client; _instanceName = instanceName; _logger = logger; - _auditCorrelationId = auditCorrelationId; + _executionId = executionId; _auditWriter = auditWriter; _siteId = siteId; _sourceScript = sourceScript; @@ -567,7 +570,11 @@ public class ScriptRuntimeContext OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.CachedSubmit, + // CorrelationId stays the per-operation lifecycle id + // (TrackedOperationId); ExecutionId carries the + // per-execution id shared across this script run. CorrelationId = trackedId.Value, + ExecutionId = _executionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, @@ -677,7 +684,10 @@ public class ScriptRuntimeContext OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCallCached, + // CorrelationId = per-operation lifecycle id; + // ExecutionId = per-execution id for this script run. CorrelationId = trackedId.Value, + ExecutionId = _executionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, @@ -738,7 +748,10 @@ public class ScriptRuntimeContext OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.CachedResolve, + // CorrelationId = per-operation lifecycle id; + // ExecutionId = per-execution id for this script run. CorrelationId = trackedId.Value, + ExecutionId = _executionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, @@ -910,9 +923,12 @@ public class ScriptRuntimeContext OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, - // Audit Log #23: the execution-wide correlation id, so all the - // sync ApiCall/DbWrite rows from one script run share an id. - CorrelationId = _auditCorrelationId, + // Audit Log #23: a sync one-shot call has no operation + // lifecycle, so CorrelationId is null. ExecutionId carries the + // per-execution id so all the sync ApiCall/DbWrite rows from + // one script run can be correlated together. + CorrelationId = null, + ExecutionId = _executionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, @@ -979,7 +995,7 @@ public class ScriptRuntimeContext private readonly IDatabaseGateway? _gateway; private readonly string _instanceName; private readonly ILogger _logger; - private readonly Guid _auditCorrelationId; + private readonly Guid _executionId; private readonly string _siteId; private readonly string? _sourceScript; private readonly ICachedCallTelemetryForwarder? _cachedForwarder; @@ -996,7 +1012,7 @@ public class ScriptRuntimeContext /// private readonly IAuditWriter? _auditWriter; - // Parameter ordering: auditCorrelationId sits immediately after the + // Parameter ordering: executionId sits immediately after the // ILogger — see the note on ExternalSystemHelper's ctor for why the // post-logger slot is the one consistent position across all four // audit-threaded ctors. @@ -1004,7 +1020,7 @@ public class ScriptRuntimeContext IDatabaseGateway? gateway, string instanceName, ILogger logger, - Guid auditCorrelationId, + Guid executionId, IAuditWriter? auditWriter = null, string siteId = "", string? sourceScript = null, @@ -1013,7 +1029,7 @@ public class ScriptRuntimeContext _gateway = gateway; _instanceName = instanceName; _logger = logger; - _auditCorrelationId = auditCorrelationId; + _executionId = executionId; _auditWriter = auditWriter; _siteId = siteId; _sourceScript = sourceScript; @@ -1049,7 +1065,7 @@ public class ScriptRuntimeContext instanceName: _instanceName, sourceScript: _sourceScript, logger: _logger, - auditCorrelationId: _auditCorrelationId); + executionId: _executionId); } /// @@ -1116,7 +1132,10 @@ public class ScriptRuntimeContext OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), Channel = AuditChannel.DbOutbound, Kind = AuditKind.CachedSubmit, + // CorrelationId = per-operation lifecycle id + // (TrackedOperationId); ExecutionId = per-execution id. CorrelationId = trackedId.Value, + ExecutionId = _executionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, @@ -1178,6 +1197,12 @@ public class ScriptRuntimeContext private readonly TimeSpan _askTimeout; private readonly ILogger _logger; + /// + /// Audit Log #23: the per-execution id for this script run, stamped + /// into AuditEvent.ExecutionId on the NotifySend row. + /// + private readonly Guid _executionId; + /// /// Audit Log #23 (M4 Bundle C): best-effort emitter for the /// Notification/NotifySend row produced when the script @@ -1188,6 +1213,8 @@ public class ScriptRuntimeContext /// private readonly IAuditWriter? _auditWriter; + // Parameter ordering: executionId sits immediately after the ILogger, + // consistent with the other audit-threaded ctors. internal NotifyHelper( StoreAndForwardService? storeAndForward, ICanTell? siteCommunicationActor, @@ -1196,6 +1223,7 @@ public class ScriptRuntimeContext string? sourceScript, TimeSpan askTimeout, ILogger logger, + Guid executionId, IAuditWriter? auditWriter = null) { _storeAndForward = storeAndForward; @@ -1205,6 +1233,7 @@ public class ScriptRuntimeContext _sourceScript = sourceScript; _askTimeout = askTimeout; _logger = logger; + _executionId = executionId; _auditWriter = auditWriter; } @@ -1215,6 +1244,9 @@ public class ScriptRuntimeContext { return new NotifyTarget( listName, _storeAndForward, _siteId, _instanceName, _sourceScript, _logger, + // Audit Log #23: the per-execution id stamped into the + // NotifySend row's ExecutionId column. + _executionId, // Audit Log #23 (M4 Bundle C): forward the writer so Send() // can emit one NotifySend(Submitted) row per accepted submission. _auditWriter); @@ -1292,6 +1324,12 @@ public class ScriptRuntimeContext private readonly string? _sourceScript; private readonly ILogger _logger; + /// + /// Audit Log #23: the per-execution id for this script run, stamped + /// into AuditEvent.ExecutionId on the NotifySend row. + /// + private readonly Guid _executionId; + /// /// Audit Log #23 (M4 Bundle C): best-effort emitter for the /// Notification/NotifySend row written immediately after @@ -1307,6 +1345,7 @@ public class ScriptRuntimeContext string instanceName, string? sourceScript, ILogger logger, + Guid executionId, IAuditWriter? auditWriter = null) { _listName = listName; @@ -1315,6 +1354,7 @@ public class ScriptRuntimeContext _instanceName = instanceName; _sourceScript = sourceScript; _logger = logger; + _executionId = executionId; _auditWriter = auditWriter; } @@ -1431,7 +1471,10 @@ public class ScriptRuntimeContext OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), Channel = AuditChannel.Notification, Kind = AuditKind.NotifySend, + // CorrelationId is the NotificationId-derived per-operation + // lifecycle id; ExecutionId carries the per-execution id. CorrelationId = correlationId, + ExecutionId = _executionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs index 042a031..082ffcc 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs @@ -39,6 +39,12 @@ public class DatabaseCachedWriteEmissionTests private const string InstanceName = "Plant.Pump42"; private const string SourceScript = "ScriptActor:WriteAudit"; + /// + /// Audit Log #23: a fixed per-execution id so the cached-row tests can + /// assert against a known value. + /// + private static readonly Guid TestExecutionId = Guid.NewGuid(); + private static ScriptRuntimeContext.DatabaseHelper CreateHelper( IDatabaseGateway gateway, ICachedCallTelemetryForwarder? forwarder) @@ -47,9 +53,10 @@ public class DatabaseCachedWriteEmissionTests gateway, InstanceName, NullLogger.Instance, - // Audit Log #23: execution-wide correlation id. Cached rows keep - // CorrelationId = TrackedOperationId, so any value works here. - Guid.NewGuid(), + // Audit Log #23: the per-execution id stamped into ExecutionId on + // every script-side row. Cached rows keep CorrelationId = + // TrackedOperationId (the per-operation lifecycle id). + TestExecutionId, siteId: SiteId, sourceScript: SourceScript, cachedForwarder: forwarder); @@ -79,7 +86,10 @@ public class DatabaseCachedWriteEmissionTests Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind); Assert.Equal(AuditStatus.Submitted, packet.Audit.Status); Assert.Equal("myDb", packet.Audit.Target); + // CorrelationId is the per-operation lifecycle id (TrackedOperationId); + // ExecutionId is the per-execution id from the runtime context. Assert.Equal(trackedId.Value, packet.Audit.CorrelationId); + Assert.Equal(TestExecutionId, packet.Audit.ExecutionId); Assert.Equal(trackedId, packet.Operational.TrackedOperationId); Assert.Equal("DbOutbound", packet.Operational.Channel); diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs index 40f2986..021cae5 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs @@ -49,27 +49,27 @@ public class DatabaseSyncEmissionTests private const string ConnectionName = "machineData"; /// - /// Audit Log #23: a fixed execution-wide correlation id used by the - /// default + /// Audit Log #23: a fixed per-execution id used by the default + /// /// overload so assertions can compare against a known value. /// - private static readonly Guid TestCorrelationId = Guid.NewGuid(); + private static readonly Guid TestExecutionId = Guid.NewGuid(); private static ScriptRuntimeContext.DatabaseHelper CreateHelper( IDatabaseGateway gateway, IAuditWriter? auditWriter) - => CreateHelper(gateway, auditWriter, TestCorrelationId); + => CreateHelper(gateway, auditWriter, TestExecutionId); private static ScriptRuntimeContext.DatabaseHelper CreateHelper( IDatabaseGateway gateway, IAuditWriter? auditWriter, - Guid correlationId) + Guid executionId) { return new ScriptRuntimeContext.DatabaseHelper( gateway, InstanceName, NullLogger.Instance, - correlationId, + executionId, auditWriter: auditWriter, siteId: SiteId, sourceScript: SourceScript, @@ -282,14 +282,16 @@ public class DatabaseSyncEmissionTests Assert.Equal(SourceScript, evt.SourceScript); // Outbound channel: Actor carries the calling script identity. Assert.Equal(SourceScript, evt.Actor); - // Audit Log #23: the sync DbWrite row now carries the execution-wide - // correlation id the helper was constructed with. - Assert.Equal(TestCorrelationId, evt.CorrelationId); + // Audit Log #23: the sync DbWrite row carries the per-execution id the + // helper was constructed with in ExecutionId. CorrelationId is null — + // a sync one-shot call has no operation lifecycle. + Assert.Equal(TestExecutionId, evt.ExecutionId); + Assert.Null(evt.CorrelationId); Assert.NotEqual(Guid.Empty, evt.EventId); } [Fact] - public async Task SyncDbWrite_StampsExecutionCorrelationId() + public async Task SyncDbWrite_StampsExecutionId_AndNullCorrelationId() { using var keepAlive = new SqliteConnection("Data Source=kc;Mode=Memory;Cache=Shared"); var inner = NewInMemoryDb(out var _); @@ -298,16 +300,18 @@ public class DatabaseSyncEmissionTests .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny())) .ReturnsAsync(inner); var writer = new CapturingAuditWriter(); - var correlationId = Guid.NewGuid(); + var executionId = Guid.NewGuid(); - var helper = CreateHelper(gateway.Object, writer, correlationId); + var helper = CreateHelper(gateway.Object, writer, executionId); await using var conn = await helper.Connection(ConnectionName); await using var cmd = conn.CreateCommand(); cmd.CommandText = "INSERT INTO t (id, name) VALUES (7, 'eta')"; await cmd.ExecuteNonQueryAsync(); var evt = Assert.Single(writer.Events); - Assert.Equal(correlationId, evt.CorrelationId); + Assert.Equal(executionId, evt.ExecutionId); + // Sync one-shot call: CorrelationId is null (no operation lifecycle). + Assert.Null(evt.CorrelationId); } [Fact] diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs index 13a54dc..5e85efb 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs @@ -16,13 +16,15 @@ namespace ScadaLink.SiteRuntime.Tests.Scripts; /// /// /// The ?? Guid.NewGuid() fallback in the -/// ctor: when no audit correlation id is supplied (tag-change / timer-triggered +/// ctor: when no execution id is supplied (tag-change / timer-triggered /// executions) a fresh, non-empty id is minted and stamped on the emitted rows. /// /// /// The execution-wide contract: an ExternalSystem.Call and a sync /// Database write performed through ONE context share a single -/// . +/// . The per-operation +/// stays null for these sync one-shot +/// calls — a sync call has no operation lifecycle. /// /// /// @@ -53,14 +55,14 @@ public class ExecutionCorrelationContextTests /// system client, database gateway and audit writer the cross-helper test /// needs. The actor refs are — the /// integration helpers (ExternalSystem / Database) never touch them — and - /// defaults to null so the ctor's + /// defaults to null so the ctor's /// ?? Guid.NewGuid() fallback is exercised unless a test supplies one. /// private static ScriptRuntimeContext CreateContext( IExternalSystemClient? externalSystemClient, IDatabaseGateway? databaseGateway, IAuditWriter? auditWriter, - Guid? auditCorrelationId = null) + Guid? executionId = null) { var compilationService = new ScriptCompilationService( NullLogger.Instance); @@ -85,7 +87,7 @@ public class ExecutionCorrelationContextTests auditWriter: auditWriter, operationTrackingStore: null, cachedForwarder: null, - auditCorrelationId: auditCorrelationId); + executionId: executionId); } /// @@ -113,9 +115,9 @@ public class ExecutionCorrelationContextTests } [Fact] - public async Task NoCorrelationIdSupplied_SyncCall_StampsFreshNonEmptyCorrelationId() + public async Task NoExecutionIdSupplied_SyncCall_StampsFreshNonEmptyExecutionId() { - // No auditCorrelationId argument — the ScriptRuntimeContext ctor's + // No executionId argument — the ScriptRuntimeContext ctor's // `?? Guid.NewGuid()` fallback must mint one (this is the unsupplied-id // branch every other audit test bypasses by passing an explicit id). var client = new Mock(); @@ -128,17 +130,19 @@ public class ExecutionCorrelationContextTests await context.ExternalSystem.Call("ERP", "GetOrder"); var evt = Assert.Single(writer.Events); - Assert.NotNull(evt.CorrelationId); - Assert.NotEqual(Guid.Empty, evt.CorrelationId!.Value); + Assert.NotNull(evt.ExecutionId); + Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value); + // A sync one-shot call has no operation lifecycle — CorrelationId is null. + Assert.Null(evt.CorrelationId); } [Fact] - public async Task SameContext_ApiCallAndDbWrite_ShareTheSameCorrelationId() + public async Task SameContext_ApiCallAndDbWrite_ShareTheSameExecutionId() { // The execution-wide contract: an ExternalSystem.Call AND a sync // Database write performed through ONE ScriptRuntimeContext must both - // carry the same execution correlation id, so an audit reader can tie - // every trust-boundary action from one script run together. + // carry the same ExecutionId, so an audit reader can tie every + // trust-boundary action from one script run together. using var keepAlive = new SqliteConnection("Data Source=ecc;Mode=Memory;Cache=Shared"); var innerDb = NewInMemoryDb(out var _); @@ -170,10 +174,13 @@ public class ExecutionCorrelationContextTests var apiRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.ApiOutbound); var dbRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.DbOutbound); - Assert.NotNull(apiRow.CorrelationId); - Assert.NotEqual(Guid.Empty, apiRow.CorrelationId!.Value); + Assert.NotNull(apiRow.ExecutionId); + Assert.NotEqual(Guid.Empty, apiRow.ExecutionId!.Value); // The ApiCall row and the DbWrite row, emitted by two different helpers - // resolved off one context, carry the identical execution correlation id. - Assert.Equal(apiRow.CorrelationId, dbRow.CorrelationId); + // resolved off one context, carry the identical ExecutionId. + Assert.Equal(apiRow.ExecutionId, dbRow.ExecutionId); + // Both are sync one-shot calls — neither carries a CorrelationId. + Assert.Null(apiRow.CorrelationId); + Assert.Null(dbRow.CorrelationId); } } diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs index f2edfa8..c65d93b 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs @@ -41,6 +41,12 @@ public class ExternalSystemCachedCallEmissionTests private const string InstanceName = "Plant.Pump42"; private const string SourceScript = "ScriptActor:CheckPressure"; + /// + /// Audit Log #23: a fixed per-execution id so the cached-row tests can + /// assert against a known value. + /// + private static readonly Guid TestExecutionId = Guid.NewGuid(); + private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper( IExternalSystemClient client, ICachedCallTelemetryForwarder? forwarder) @@ -49,9 +55,10 @@ public class ExternalSystemCachedCallEmissionTests client, InstanceName, NullLogger.Instance, - // Audit Log #23: execution-wide correlation id. Cached rows keep - // CorrelationId = TrackedOperationId, so any value works here. - Guid.NewGuid(), + // Audit Log #23: the per-execution id stamped into ExecutionId on + // every script-side row. Cached rows keep CorrelationId = + // TrackedOperationId (the per-operation lifecycle id). + TestExecutionId, auditWriter: null, siteId: SiteId, sourceScript: SourceScript, @@ -83,7 +90,10 @@ public class ExternalSystemCachedCallEmissionTests Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind); Assert.Equal(AuditStatus.Submitted, packet.Audit.Status); Assert.Equal("ERP.GetOrder", packet.Audit.Target); + // CorrelationId is the per-operation lifecycle id (TrackedOperationId); + // ExecutionId is the per-execution id from the runtime context. Assert.Equal(trackedId.Value, packet.Audit.CorrelationId); + Assert.Equal(TestExecutionId, packet.Audit.ExecutionId); Assert.Equal(AuditForwardState.Pending, packet.Audit.ForwardState); // Operational mirror — same id, Submitted, RetryCount 0, not terminal. @@ -298,6 +308,7 @@ public class ExternalSystemCachedCallEmissionTests var submit = forwarder.Telemetry[0]; Assert.Equal(AuditKind.CachedSubmit, submit.Audit.Kind); Assert.Equal(AuditStatus.Submitted, submit.Audit.Status); + Assert.Equal(TestExecutionId, submit.Audit.ExecutionId); Assert.Equal(trackedId, submit.Operational.TrackedOperationId); Assert.Null(submit.Operational.TerminalAtUtc); @@ -305,7 +316,10 @@ public class ExternalSystemCachedCallEmissionTests Assert.Equal(AuditChannel.ApiOutbound, attempted.Audit.Channel); Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.Kind); Assert.Equal(AuditStatus.Attempted, attempted.Audit.Status); + // Cached rows: CorrelationId = TrackedOperationId; ExecutionId is the + // per-execution id from the runtime context. Assert.Equal(trackedId.Value, attempted.Audit.CorrelationId); + Assert.Equal(TestExecutionId, attempted.Audit.ExecutionId); Assert.Equal("ERP.GetOrder", attempted.Audit.Target); Assert.Equal(trackedId, attempted.Operational.TrackedOperationId); Assert.Equal("Attempted", attempted.Operational.Status); @@ -316,6 +330,7 @@ public class ExternalSystemCachedCallEmissionTests Assert.Equal(AuditKind.CachedResolve, resolve.Audit.Kind); Assert.Equal(AuditStatus.Delivered, resolve.Audit.Status); Assert.Equal(trackedId.Value, resolve.Audit.CorrelationId); + Assert.Equal(TestExecutionId, resolve.Audit.ExecutionId); Assert.Equal(trackedId, resolve.Operational.TrackedOperationId); Assert.Equal("Delivered", resolve.Operational.Status); // Terminal row carries TerminalAtUtc. diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs index 209946e..6632783 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs @@ -46,27 +46,27 @@ public class ExternalSystemCallAuditEmissionTests private const string SourceScript = "ScriptActor:CheckPressure"; /// - /// Audit Log #23: a fixed execution-wide correlation id used by the - /// default + /// Audit Log #23: a fixed per-execution id used by the default + /// /// overload so assertions can compare against a known value. /// - private static readonly Guid TestCorrelationId = Guid.NewGuid(); + private static readonly Guid TestExecutionId = Guid.NewGuid(); private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper( IExternalSystemClient client, IAuditWriter? auditWriter) - => CreateHelper(client, auditWriter, TestCorrelationId); + => CreateHelper(client, auditWriter, TestExecutionId); private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper( IExternalSystemClient client, IAuditWriter? auditWriter, - Guid correlationId) + Guid executionId) { return new ScriptRuntimeContext.ExternalSystemHelper( client, InstanceName, NullLogger.Instance, - correlationId, + executionId, auditWriter, SiteId, SourceScript); @@ -225,47 +225,54 @@ public class ExternalSystemCallAuditEmissionTests Assert.Equal(SourceScript, evt.SourceScript); // Outbound channel: Actor carries the calling script identity. Assert.Equal(SourceScript, evt.Actor); - // Audit Log #23: the sync ApiCall row now carries the execution-wide - // correlation id the helper was constructed with. - Assert.Equal(TestCorrelationId, evt.CorrelationId); + // Audit Log #23: the sync ApiCall row carries the per-execution id the + // helper was constructed with in ExecutionId. CorrelationId is null — + // a sync one-shot call has no operation lifecycle. + Assert.Equal(TestExecutionId, evt.ExecutionId); + Assert.Null(evt.CorrelationId); } [Fact] - public async Task Call_SyncApiCall_StampsExecutionCorrelationId() + public async Task Call_SyncApiCall_StampsExecutionId_AndNullCorrelationId() { 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 correlationId = Guid.NewGuid(); + var executionId = Guid.NewGuid(); - var helper = CreateHelper(client.Object, writer, correlationId); + var helper = CreateHelper(client.Object, writer, executionId); await helper.Call("ERP", "GetOrder"); var evt = Assert.Single(writer.Events); - Assert.Equal(correlationId, evt.CorrelationId); + Assert.Equal(executionId, evt.ExecutionId); + // Sync one-shot call: CorrelationId is null (no operation lifecycle). + Assert.Null(evt.CorrelationId); } [Fact] - public async Task Call_TwoCallsOnSameHelper_ShareTheSameCorrelationId() + public async Task Call_TwoCallsOnSameHelper_ShareTheSameExecutionId() { var client = new Mock(); client .Setup(c => c.CallAsync(It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, "{}", null)); var writer = new CapturingAuditWriter(); - var correlationId = Guid.NewGuid(); + var executionId = Guid.NewGuid(); - var helper = CreateHelper(client.Object, writer, correlationId); + var helper = CreateHelper(client.Object, writer, executionId); await helper.Call("ERP", "GetOrder"); await helper.Call("ERP", "GetCustomer"); Assert.Equal(2, writer.Events.Count); - // Both sync ApiCall rows from one execution carry the same id. - Assert.Equal(correlationId, writer.Events[0].CorrelationId); - Assert.Equal(correlationId, writer.Events[1].CorrelationId); - Assert.Equal(writer.Events[0].CorrelationId, writer.Events[1].CorrelationId); + // Both sync ApiCall rows from one execution carry the same ExecutionId. + Assert.Equal(executionId, writer.Events[0].ExecutionId); + Assert.Equal(executionId, writer.Events[1].ExecutionId); + Assert.Equal(writer.Events[0].ExecutionId, writer.Events[1].ExecutionId); + // Neither sync call carries a CorrelationId. + Assert.Null(writer.Events[0].CorrelationId); + Assert.Null(writer.Events[1].CorrelationId); } [Fact] diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs index 509e8a6..adfec68 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs @@ -69,7 +69,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable "Plant.Pump3", sourceScript, TimeSpan.FromSeconds(3), - NullLogger.Instance); + NullLogger.Instance, + Guid.NewGuid()); } [Fact] diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs index 7218ad9..402821b 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs @@ -53,6 +53,12 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable private const string Subject = "Pump alarm"; private const string Body = "Pump 3 tripped"; + /// + /// Audit Log #23: a fixed per-execution id so the NotifySend test can + /// assert against a known value. + /// + private static readonly Guid TestExecutionId = Guid.NewGuid(); + private readonly SqliteConnection _keepAlive; private readonly StoreAndForwardStorage _storage; private readonly StoreAndForwardService _saf; @@ -102,6 +108,7 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable sourceScript, TimeSpan.FromSeconds(3), NullLogger.Instance, + TestExecutionId, auditWriter); } @@ -214,12 +221,14 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable // NotificationId is minted as Guid.NewGuid().ToString("N") — the 32-char // hex form, which Guid.TryParse accepts. The audit row's CorrelationId - // must round-trip back to the same Guid value. + // must round-trip back to the same Guid value (the per-operation + // lifecycle id). ExecutionId carries the per-execution id instead. Assert.True(Guid.TryParse(notificationId, out var expected), $"NotificationId '{notificationId}' should be a parseable Guid"); var evt = writer.Events[0]; Assert.NotNull(evt.CorrelationId); Assert.Equal(expected, evt.CorrelationId); + Assert.Equal(TestExecutionId, evt.ExecutionId); } [Fact] From 6f5a35f222ffedd90995e9dcfa66aaf1898427f2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 15:18:35 -0400 Subject: [PATCH 07/15] feat(auditlog): thread ExecutionId through S&F for retry-loop cached rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store-and-forward retry loop emits the per-attempt and terminal cached audit rows (ApiCallCached/DbWriteCached Attempted, CachedResolve) via CachedCallLifecycleBridge from a CachedCallAttemptContext, not from the script context. ExecutionId (and SourceScript) were not threaded through the S&F buffer, so those rows had ExecutionId = null and SourceScript = null. Thread both, additively, from the cached-call enqueue path: - StoreAndForwardMessage gains ExecutionId (Guid?) / SourceScript (string?). - StoreAndForwardStorage adds nullable execution_id / source_script columns via an idempotent PRAGMA-probed ALTER TABLE migration; rows persisted by an older build read back null (back-compat). - StoreAndForwardService.EnqueueAsync gains optional executionId / sourceScript params, stamped onto the buffered message and surfaced on the CachedCallAttemptContext built in the retry loop. - CachedCallAttemptContext gains ExecutionId / SourceScript. - CachedCallLifecycleBridge.BuildPacket sets AuditEvent.ExecutionId and AuditEvent.SourceScript from the context (replacing the hard-coded SourceScript = null and its now-stale comment). - IExternalSystemClient.CachedCallAsync / IDatabaseGateway.CachedWriteAsync gain optional executionId / sourceScript params; ScriptRuntimeContext's CachedCall / CachedWrite helpers pass _executionId / _sourceScript. Script-side cached rows (CachedSubmit, immediate Attempted+Resolve) are unchanged. All threading is additive — old buffered S&F rows still deserialize and process with the new fields null. --- .../Telemetry/CachedCallLifecycleBridge.cs | 10 +- .../Services/ICachedCallLifecycleObserver.cs | 18 ++- .../Interfaces/Services/IDatabaseGateway.cs | 15 ++- .../Services/IExternalSystemClient.cs | 15 ++- .../DatabaseGateway.cs | 12 +- .../ExternalSystemClient.cs | 12 +- .../Scripts/ScriptRuntimeContext.cs | 14 ++- .../StoreAndForwardMessage.cs | 21 ++++ .../StoreAndForwardService.cs | 29 ++++- .../StoreAndForwardStorage.cs | 64 +++++++++- .../CachedCallLifecycleBridgeTests.cs | 79 +++++++++++- .../DatabaseCachedWriteEmissionTests.cs | 18 ++- .../ExternalSystemCachedCallEmissionTests.cs | 39 ++++-- .../CachedCallAttemptEmissionTests.cs | 80 ++++++++++++ .../StoreAndForwardStorageTests.cs | 119 ++++++++++++++++++ 15 files changed, 505 insertions(+), 40 deletions(-) diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs index cc6a975..258bbaa 100644 --- a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs +++ b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs @@ -133,9 +133,17 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver Channel = channel, Kind = kind, CorrelationId = context.TrackedOperationId.Value, + // Audit Log #23 (ExecutionId Task 4): the originating script + // execution's per-run correlation id, threaded through the S&F + // buffer; null on rows buffered before Task 4 (back-compat). + ExecutionId = context.ExecutionId, SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite, SourceInstanceId = context.SourceInstanceId, - SourceScript = null, // Not threaded through S&F; left null on retry-loop rows. + // Audit Log #23 (ExecutionId Task 4): SourceScript is now + // threaded through the S&F buffer alongside ExecutionId — the + // retry-loop cached rows carry the same provenance the + // script-side cached rows do. Null on pre-Task-4 buffered rows. + SourceScript = context.SourceScript, Target = context.Target, Status = status, HttpStatus = httpStatus, diff --git a/src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs b/src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs index 8e34cb4..6188f0c 100644 --- a/src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs +++ b/src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs @@ -57,6 +57,20 @@ public interface ICachedCallLifecycleObserver /// When this attempt completed. /// Duration of the attempt in milliseconds (null when not measured). /// Originating instance, when known. +/// +/// Audit Log #23 (ExecutionId Task 4): the originating script execution's +/// per-run correlation id, threaded through the store-and-forward buffer from +/// the cached-call enqueue path. The audit bridge stamps it onto the +/// retry-loop ApiCallCached/DbWriteCached Attempted and +/// CachedResolve rows so they correlate with the rest of the run. +/// null for rows buffered before Task 4 (back-compat). +/// +/// +/// Audit Log #23 (ExecutionId Task 4): the originating script identifier, +/// threaded alongside so the retry-loop audit +/// rows carry the same SourceScript provenance the script-side cached +/// rows already do. null when not known. +/// public sealed record CachedCallAttemptContext( TrackedOperationId TrackedOperationId, string Channel, @@ -69,7 +83,9 @@ public sealed record CachedCallAttemptContext( DateTime CreatedAtUtc, DateTime OccurredAtUtc, int? DurationMs, - string? SourceInstanceId); + string? SourceInstanceId, + Guid? ExecutionId = null, + string? SourceScript = null); /// /// Coarse outcome of one cached-call delivery attempt, observed from inside diff --git a/src/ScadaLink.Commons/Interfaces/Services/IDatabaseGateway.cs b/src/ScadaLink.Commons/Interfaces/Services/IDatabaseGateway.cs index 60defe9..0271bec 100644 --- a/src/ScadaLink.Commons/Interfaces/Services/IDatabaseGateway.cs +++ b/src/ScadaLink.Commons/Interfaces/Services/IDatabaseGateway.cs @@ -29,11 +29,24 @@ public interface IDatabaseGateway /// null — when omitted the S&F engine mints a fresh GUID and no /// M3 telemetry is correlated (pre-M3 caller behaviour). /// + /// + /// Audit Log #23 (ExecutionId Task 4): the originating script execution's + /// per-run correlation id. When the write is buffered on a transient + /// failure this is threaded onto the S&F message so the retry-loop + /// cached-write audit rows carry it. null when not threaded. + /// + /// + /// Audit Log #23 (ExecutionId Task 4): the originating script identifier, + /// threaded onto the buffered S&F message alongside + /// . null when not known. + /// Task CachedWriteAsync( string connectionName, string sql, IReadOnlyDictionary? parameters = null, string? originInstanceName = null, CancellationToken cancellationToken = default, - TrackedOperationId? trackedOperationId = null); + TrackedOperationId? trackedOperationId = null, + Guid? executionId = null, + string? sourceScript = null); } diff --git a/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs b/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs index 627a81d..d4f855c 100644 --- a/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs +++ b/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs @@ -30,13 +30,26 @@ public interface IExternalSystemClient /// M3 telemetry is correlated (the legacy behaviour pre-M3 callers rely /// on). /// + /// + /// Audit Log #23 (ExecutionId Task 4): the originating script execution's + /// per-run correlation id. When the call is buffered on a transient + /// failure this is threaded onto the S&F message so the retry-loop + /// cached-call audit rows carry it. null when not threaded. + /// + /// + /// Audit Log #23 (ExecutionId Task 4): the originating script identifier, + /// threaded onto the buffered S&F message alongside + /// . null when not known. + /// Task CachedCallAsync( string systemName, string methodName, IReadOnlyDictionary? parameters = null, string? originInstanceName = null, CancellationToken cancellationToken = default, - TrackedOperationId? trackedOperationId = null); + TrackedOperationId? trackedOperationId = null, + Guid? executionId = null, + string? sourceScript = null); } /// diff --git a/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs b/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs index 7e48288..efa29c4 100644 --- a/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs +++ b/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs @@ -84,7 +84,9 @@ public class DatabaseGateway : IDatabaseGateway IReadOnlyDictionary? parameters = null, string? originInstanceName = null, CancellationToken cancellationToken = default, - TrackedOperationId? trackedOperationId = null) + TrackedOperationId? trackedOperationId = null, + Guid? executionId = null, + string? sourceScript = null) { var definition = await ResolveConnectionAsync(connectionName, cancellationToken); if (definition == null) @@ -124,7 +126,13 @@ public class DatabaseGateway : IDatabaseGateway // read it back via StoreAndForwardMessage.Id and emit per-attempt + // terminal cached-write telemetry. Null -> S&F mints its own GUID // (legacy pre-M3 behaviour). - messageId: trackedOperationId?.ToString()); + messageId: trackedOperationId?.ToString(), + // Audit Log #23 (ExecutionId Task 4): thread the originating script + // execution's ExecutionId + SourceScript onto the buffered row so + // the retry-loop cached-write audit rows carry the same provenance + // the script-side cached rows do. + executionId: executionId, + sourceScript: sourceScript); } /// diff --git a/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs b/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs index 0b88de6..dfe042b 100644 --- a/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs +++ b/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs @@ -86,7 +86,9 @@ public class ExternalSystemClient : IExternalSystemClient IReadOnlyDictionary? parameters = null, string? originInstanceName = null, CancellationToken cancellationToken = default, - TrackedOperationId? trackedOperationId = null) + TrackedOperationId? trackedOperationId = null, + Guid? executionId = null, + string? sourceScript = null) { var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken); if (system == null || method == null) @@ -144,7 +146,13 @@ public class ExternalSystemClient : IExternalSystemClient // StoreAndForwardMessage.Id and emit per-attempt + terminal // cached-call telemetry (Bundle E Tasks E4/E5). Null -> S&F // mints its own GUID (legacy pre-M3 behaviour). - messageId: trackedOperationId?.ToString()); + messageId: trackedOperationId?.ToString(), + // Audit Log #23 (ExecutionId Task 4): thread the originating + // script execution's ExecutionId + SourceScript onto the + // buffered row so the retry-loop cached-call audit rows carry + // the same provenance the script-side cached rows do. + executionId: executionId, + sourceScript: sourceScript); return new ExternalCallResult(true, null, null, WasBuffered: true); } diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs index bfdbaa4..1d085f3 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -513,7 +513,12 @@ public class ScriptRuntimeContext parameters, _instanceName, cancellationToken, - trackedId).ConfigureAwait(false); + trackedId, + // Audit Log #23 (ExecutionId Task 4): thread the script + // execution's ExecutionId + SourceScript so a buffered + // cached call's retry-loop audit rows carry them. + executionId: _executionId, + sourceScript: _sourceScript).ConfigureAwait(false); } catch (Exception ex) { @@ -1096,7 +1101,12 @@ public class ScriptRuntimeContext try { await _gateway.CachedWriteAsync( - name, sql, parameters, _instanceName, cancellationToken, trackedId) + name, sql, parameters, _instanceName, cancellationToken, trackedId, + // Audit Log #23 (ExecutionId Task 4): thread the script + // execution's ExecutionId + SourceScript so a buffered + // cached write's retry-loop audit rows carry them. + executionId: _executionId, + sourceScript: _sourceScript) .ConfigureAwait(false); } catch (Exception ex) diff --git a/src/ScadaLink.StoreAndForward/StoreAndForwardMessage.cs b/src/ScadaLink.StoreAndForward/StoreAndForwardMessage.cs index 5695ed2..3e799ac 100644 --- a/src/ScadaLink.StoreAndForward/StoreAndForwardMessage.cs +++ b/src/ScadaLink.StoreAndForward/StoreAndForwardMessage.cs @@ -55,4 +55,25 @@ public class StoreAndForwardMessage /// WP-13: Messages are NOT cleared when instance is deleted. /// public string? OriginInstanceName { get; set; } + + /// + /// Audit Log #23 (ExecutionId Task 4): the originating script execution's + /// per-run correlation id, threaded from ScriptRuntimeContext through + /// the cached-call enqueue path. Carried so the store-and-forward retry loop + /// can stamp it onto the per-attempt / terminal cached-call audit rows + /// (ApiCallCached/DbWriteCached Attempted, CachedResolve). + /// null for non-cached-call categories (notifications) and for rows + /// buffered before this field existed — back-compat with old persisted rows + /// (the column is added by an additive migration and read as null when absent). + /// + public Guid? ExecutionId { get; set; } + + /// + /// Audit Log #23 (ExecutionId Task 4): the originating script identifier, + /// threaded alongside from the cached-call enqueue + /// path so the retry-loop audit rows carry the same SourceScript + /// provenance the script-side cached rows already carry. null when not + /// known (non-cached categories, pre-migration rows). + /// + public string? SourceScript { get; set; } } diff --git a/src/ScadaLink.StoreAndForward/StoreAndForwardService.cs b/src/ScadaLink.StoreAndForward/StoreAndForwardService.cs index 70f6c23..fe27528 100644 --- a/src/ScadaLink.StoreAndForward/StoreAndForwardService.cs +++ b/src/ScadaLink.StoreAndForward/StoreAndForwardService.cs @@ -175,6 +175,18 @@ public class StoreAndForwardService /// it is the buffered row's , it is carried /// inside the payload, and it is the id the forwarder submits to central. /// + /// + /// Audit Log #23 (ExecutionId Task 4): the originating script execution's + /// per-run correlation id. Threaded onto the buffered row so the retry-loop + /// cached-call audit rows carry it. null for callers (notifications, + /// pre-Task-4 callers) that do not supply one. + /// + /// + /// Audit Log #23 (ExecutionId Task 4): the originating script identifier, + /// threaded onto the buffered row alongside + /// so the retry-loop audit rows carry the same provenance the script-side + /// cached rows do. null when not known. + /// public async Task EnqueueAsync( StoreAndForwardCategory category, string target, @@ -183,7 +195,9 @@ public class StoreAndForwardService int? maxRetries = null, TimeSpan? retryInterval = null, bool attemptImmediateDelivery = true, - string? messageId = null) + string? messageId = null, + Guid? executionId = null, + string? sourceScript = null) { var message = new StoreAndForwardMessage { @@ -196,7 +210,9 @@ public class StoreAndForwardService RetryIntervalMs = (long)(retryInterval ?? _options.DefaultRetryInterval).TotalMilliseconds, CreatedAt = DateTimeOffset.UtcNow, Status = StoreAndForwardMessageStatus.Pending, - OriginInstanceName = originInstanceName + OriginInstanceName = originInstanceName, + ExecutionId = executionId, + SourceScript = sourceScript }; // Attempt immediate delivery — unless the caller has already made a @@ -492,7 +508,14 @@ public class StoreAndForwardService CreatedAtUtc: message.CreatedAt.UtcDateTime, OccurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), DurationMs: durationMs, - SourceInstanceId: message.OriginInstanceName); + SourceInstanceId: message.OriginInstanceName, + // Audit Log #23 (ExecutionId Task 4): the buffered message + // carries the originating script execution's ExecutionId + + // SourceScript; surface them on the context so the bridge can + // stamp the retry-loop cached audit rows. Null on rows buffered + // before Task 4 (back-compat). + ExecutionId: message.ExecutionId, + SourceScript: message.SourceScript); } catch (Exception buildEx) { diff --git a/src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs b/src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs index 7343f9b..7b92655 100644 --- a/src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs +++ b/src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs @@ -65,9 +65,45 @@ public class StoreAndForwardStorage "; await command.ExecuteNonQueryAsync(); + // Audit Log #23 (ExecutionId Task 4): additively add the execution_id / + // source_script columns. CREATE TABLE IF NOT EXISTS above does NOT add + // columns to a table that already exists from before these fields, so a + // databases created by an older build needs the columns ALTER-ed in. + // SQLite has no "ADD COLUMN IF NOT EXISTS"; the column presence is + // probed first and the ALTER skipped when already there. Both columns + // are nullable with no default, so any row buffered before this + // migration reads back ExecutionId/SourceScript = null (back-compat). + await AddColumnIfMissingAsync(connection, "execution_id", "TEXT"); + await AddColumnIfMissingAsync(connection, "source_script", "TEXT"); + _logger.LogInformation("Store-and-forward SQLite storage initialized"); } + /// + /// Audit Log #23 (ExecutionId Task 4): adds a column to sf_messages + /// only when it is not already present. SQLite lacks ADD COLUMN IF NOT + /// EXISTS, so the schema is probed via PRAGMA table_info first. + /// Idempotent — safe to run on every . + /// + private static async Task AddColumnIfMissingAsync( + SqliteConnection connection, string columnName, string columnType) + { + await using var probe = connection.CreateCommand(); + probe.CommandText = "SELECT COUNT(*) FROM pragma_table_info('sf_messages') WHERE name = @name"; + probe.Parameters.AddWithValue("@name", columnName); + var exists = Convert.ToInt32(await probe.ExecuteScalarAsync()) > 0; + if (exists) + { + return; + } + + await using var alter = connection.CreateCommand(); + // Column name + type are caller-controlled constants, never user input — + // safe to interpolate (parameters are not permitted in DDL). + alter.CommandText = $"ALTER TABLE sf_messages ADD COLUMN {columnName} {columnType}"; + await alter.ExecuteNonQueryAsync(); + } + /// /// Ensures the directory for a file-backed SQLite database exists. SQLite creates /// the database file on demand but not its parent directory, so a configured path @@ -105,9 +141,11 @@ public class StoreAndForwardStorage await using var cmd = connection.CreateCommand(); cmd.CommandText = @" INSERT INTO sf_messages (id, category, target, payload_json, retry_count, max_retries, - retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance) + retry_interval_ms, created_at, last_attempt_at, status, last_error, + origin_instance, execution_id, source_script) VALUES (@id, @category, @target, @payload, @retryCount, @maxRetries, - @retryIntervalMs, @createdAt, @lastAttempt, @status, @lastError, @origin)"; + @retryIntervalMs, @createdAt, @lastAttempt, @status, @lastError, + @origin, @executionId, @sourceScript)"; cmd.Parameters.AddWithValue("@id", message.Id); cmd.Parameters.AddWithValue("@category", (int)message.Category); @@ -122,6 +160,12 @@ public class StoreAndForwardStorage cmd.Parameters.AddWithValue("@status", (int)message.Status); cmd.Parameters.AddWithValue("@lastError", (object?)message.LastError ?? DBNull.Value); cmd.Parameters.AddWithValue("@origin", (object?)message.OriginInstanceName ?? DBNull.Value); + // Audit Log #23 (ExecutionId Task 4): the execution id is stored as its + // canonical string form ("D") so it round-trips cleanly through the + // TEXT column; null when not a cached call / not threaded. + cmd.Parameters.AddWithValue("@executionId", + message.ExecutionId.HasValue ? message.ExecutionId.Value.ToString("D") : DBNull.Value); + cmd.Parameters.AddWithValue("@sourceScript", (object?)message.SourceScript ?? DBNull.Value); await cmd.ExecuteNonQueryAsync(); } @@ -137,7 +181,8 @@ public class StoreAndForwardStorage await using var cmd = connection.CreateCommand(); cmd.CommandText = @" SELECT id, category, target, payload_json, retry_count, max_retries, - retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance + retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance, + execution_id, source_script FROM sf_messages WHERE status = @pending AND (last_attempt_at IS NULL @@ -268,7 +313,8 @@ public class StoreAndForwardStorage var categoryFilter = category.HasValue ? " AND category = @category" : ""; pageCmd.CommandText = $@" SELECT id, category, target, payload_json, retry_count, max_retries, - retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance + retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance, + execution_id, source_script FROM sf_messages WHERE status = @parked{categoryFilter} ORDER BY created_at ASC @@ -389,7 +435,8 @@ public class StoreAndForwardStorage await using var cmd = connection.CreateCommand(); cmd.CommandText = @" SELECT id, category, target, payload_json, retry_count, max_retries, - retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance + retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance, + execution_id, source_script FROM sf_messages WHERE id = @id"; cmd.Parameters.AddWithValue("@id", messageId); @@ -446,7 +493,12 @@ public class StoreAndForwardStorage LastAttemptAt = reader.IsDBNull(8) ? null : DateTimeOffset.Parse(reader.GetString(8)), Status = (StoreAndForwardMessageStatus)reader.GetInt32(9), LastError = reader.IsDBNull(10) ? null : reader.GetString(10), - OriginInstanceName = reader.IsDBNull(11) ? null : reader.GetString(11) + OriginInstanceName = reader.IsDBNull(11) ? null : reader.GetString(11), + // Audit Log #23 (ExecutionId Task 4): rows persisted before the + // additive migration have no execution_id / source_script value; + // IsDBNull guards keep those reading back as null (back-compat). + ExecutionId = reader.IsDBNull(12) ? null : Guid.Parse(reader.GetString(12)), + SourceScript = reader.IsDBNull(13) ? null : reader.GetString(13) }); } return results; diff --git a/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs index 97cbb84..1438cfe 100644 --- a/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs @@ -31,7 +31,9 @@ public class CachedCallLifecycleBridgeTests string channel = "ApiOutbound", int retryCount = 1, string? lastError = null, - int? httpStatus = null) => + int? httpStatus = null, + Guid? executionId = null, + string? sourceScript = null) => new( TrackedOperationId: _id, Channel: channel, @@ -44,7 +46,9 @@ public class CachedCallLifecycleBridgeTests CreatedAtUtc: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc), OccurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), DurationMs: 42, - SourceInstanceId: "Plant.Pump42"); + SourceInstanceId: "Plant.Pump42", + ExecutionId: executionId, + SourceScript: sourceScript); [Fact] public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve() @@ -184,4 +188,75 @@ public class CachedCallLifecycleBridgeTests Assert.Equal(42, captured.Audit.DurationMs); Assert.Equal(_id.Value, captured.Audit.CorrelationId); } + + // ── Audit Log #23 (ExecutionId Task 4): ExecutionId / SourceScript ── + + [Fact] + public async Task RetryLoopAttemptedRow_CarriesExecutionIdAndSourceScript_FromContext() + { + // Task 4: the ExecutionId + SourceScript threaded through the S&F + // buffer arrive on the CachedCallAttemptContext; the bridge must stamp + // both onto the per-attempt ApiCallCached row (previously SourceScript + // was hard-coded null with a "not threaded through S&F" comment). + var executionId = Guid.NewGuid(); + var captured = new List(); + _forwarder.ForwardAsync(Arg.Do(t => captured.Add(t)), Arg.Any()) + .Returns(Task.CompletedTask); + + var sut = CreateSut(); + await sut.OnAttemptCompletedAsync(Ctx( + CachedCallAttemptOutcome.TransientFailure, + executionId: executionId, + sourceScript: "Plant.Pump42/OnTick")); + + var packet = Assert.Single(captured); + Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind); + Assert.Equal(executionId, packet.Audit.ExecutionId); + Assert.Equal("Plant.Pump42/OnTick", packet.Audit.SourceScript); + } + + [Fact] + public async Task RetryLoopCachedResolveRow_CarriesExecutionIdAndSourceScript_FromContext() + { + // The terminal CachedResolve row must also carry the threaded + // provenance so the whole retry-loop lifecycle is correlated. + var executionId = Guid.NewGuid(); + var captured = new List(); + _forwarder.ForwardAsync(Arg.Do(t => captured.Add(t)), Arg.Any()) + .Returns(Task.CompletedTask); + + var sut = CreateSut(); + await sut.OnAttemptCompletedAsync(Ctx( + CachedCallAttemptOutcome.Delivered, + channel: "DbOutbound", + executionId: executionId, + sourceScript: "Plant.Tank/OnAlarm")); + + Assert.Equal(2, captured.Count); + var resolve = Assert.Single(captured, p => p.Audit.Kind == AuditKind.CachedResolve); + Assert.Equal(executionId, resolve.Audit.ExecutionId); + Assert.Equal("Plant.Tank/OnAlarm", resolve.Audit.SourceScript); + + var attempted = Assert.Single(captured, p => p.Audit.Kind == AuditKind.DbWriteCached); + Assert.Equal(executionId, attempted.Audit.ExecutionId); + Assert.Equal("Plant.Tank/OnAlarm", attempted.Audit.SourceScript); + } + + [Fact] + public async Task RetryLoopRow_NullExecutionIdAndSourceScript_RemainNull() + { + // Back-compat: a pre-Task-4 buffered row has no ExecutionId / + // SourceScript; the bridge must leave the audit row's fields null + // rather than throwing. + CachedCallTelemetry? captured = null; + _forwarder.ForwardAsync(Arg.Do(t => captured = t), Arg.Any()) + .Returns(Task.CompletedTask); + + var sut = CreateSut(); + await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure)); + + Assert.NotNull(captured); + Assert.Null(captured!.Audit.ExecutionId); + Assert.Null(captured.Audit.SourceScript); + } } diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs index 082ffcc..d4a9b2f 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs @@ -72,7 +72,8 @@ public class DatabaseCachedWriteEmissionTests It.IsAny?>(), InstanceName, It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var forwarder = new CapturingForwarder(); @@ -110,7 +111,8 @@ public class DatabaseCachedWriteEmissionTests It.IsAny?>(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var forwarder = new CapturingForwarder(); @@ -134,7 +136,8 @@ public class DatabaseCachedWriteEmissionTests It.IsAny?>(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var forwarder = new CapturingForwarder(); @@ -147,7 +150,8 @@ public class DatabaseCachedWriteEmissionTests It.IsAny?>(), InstanceName, It.IsAny(), - trackedId), + trackedId, + It.IsAny(), It.IsAny()), Times.Once); } @@ -161,7 +165,8 @@ public class DatabaseCachedWriteEmissionTests It.IsAny?>(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var forwarder = new CapturingForwarder { @@ -177,7 +182,8 @@ public class DatabaseCachedWriteEmissionTests It.IsAny?>(), InstanceName, It.IsAny(), - trackedId), + trackedId, + It.IsAny(), It.IsAny()), Times.Once); } } diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs index c65d93b..8f7dde9 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs @@ -75,7 +75,8 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny?>(), InstanceName, It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var forwarder = new CapturingForwarder(); @@ -117,7 +118,8 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny?>(), InstanceName, It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false)); var forwarder = new CapturingForwarder(); @@ -153,7 +155,8 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny?>(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var forwarder = new CapturingForwarder(); @@ -172,14 +175,16 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny?>(), InstanceName, It.IsAny(), - id1), + id1, + It.IsAny(), It.IsAny()), Times.Once); client.Verify(c => c.CachedCallAsync( "ERP", "GetOrder", It.IsAny?>(), InstanceName, It.IsAny(), - id2), + id2, + It.IsAny(), It.IsAny()), Times.Once); } @@ -193,7 +198,8 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny?>(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var forwarder = new CapturingForwarder { @@ -212,7 +218,8 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny?>(), InstanceName, It.IsAny(), - trackedId), + trackedId, + It.IsAny(), It.IsAny()), Times.Once); } @@ -226,7 +233,8 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny?>(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var forwarder = new CapturingForwarder(); @@ -252,7 +260,8 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny?>(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var helper = CreateHelper(client.Object, forwarder: null); @@ -264,7 +273,8 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny?>(), InstanceName, It.IsAny(), - trackedId), + trackedId, + It.IsAny(), It.IsAny()), Times.Once); } @@ -293,7 +303,8 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny?>(), InstanceName, It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny(), It.IsAny())) // WasBuffered=false — the immediate HTTP attempt succeeded; S&F // is bypassed entirely. .ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false)); @@ -354,7 +365,8 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny?>(), InstanceName, It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult( false, null, "Permanent error: HTTP 422 bad payload", WasBuffered: false)); var forwarder = new CapturingForwarder(); @@ -396,7 +408,8 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny?>(), InstanceName, It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny(), It.IsAny())) // S&F took ownership — Attempted + Resolve come from the // CachedCallLifecycleBridge driven by the retry loop, not the helper. .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); diff --git a/tests/ScadaLink.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs b/tests/ScadaLink.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs index 05a8233..501bc3b 100644 --- a/tests/ScadaLink.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs +++ b/tests/ScadaLink.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs @@ -277,6 +277,86 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable Assert.Equal(trackedId, _observer.Notifications[1].TrackedOperationId); } + // ── Audit Log #23 (ExecutionId Task 4): ExecutionId / SourceScript ── + + [Fact] + public async Task Attempt_CarriesExecutionIdAndSourceScript_FromBufferedMessage() + { + // A buffered cached call carries the originating script execution's + // ExecutionId + SourceScript. The retry sweep must surface both on the + // CachedCallAttemptContext handed to the observer so the audit bridge + // can stamp them on the retry-loop cached rows. + var executionId = Guid.NewGuid(); + _service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem, + _ => throw new HttpRequestException("HTTP 503")); + + var trackedId = TrackedOperationId.New(); + await _service.EnqueueAsync( + StoreAndForwardCategory.ExternalSystem, + "ERP", + """{"payload":"x"}""", + originInstanceName: "Plant.Pump42", + maxRetries: 5, + retryInterval: TimeSpan.Zero, + attemptImmediateDelivery: false, + messageId: trackedId.ToString(), + executionId: executionId, + sourceScript: "Plant.Pump42/OnTick"); + + await _service.RetryPendingMessagesAsync(); + + var notification = Assert.Single(_observer.Notifications); + Assert.Equal(executionId, notification.ExecutionId); + Assert.Equal("Plant.Pump42/OnTick", notification.SourceScript); + } + + [Fact] + public async Task Attempt_NullExecutionIdAndSourceScript_SurfaceAsNull() + { + // Back-compat: a row buffered without ExecutionId / SourceScript (legacy + // enqueue path) must surface them as null on the context, not throw. + _service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem, + _ => Task.FromResult(true)); + var trackedId = await EnqueueBufferedAsync( + StoreAndForwardCategory.ExternalSystem, "ERP"); + + await _service.RetryPendingMessagesAsync(); + + var notification = Assert.Single(_observer.Notifications); + Assert.Null(notification.ExecutionId); + Assert.Null(notification.SourceScript); + } + + [Fact] + public async Task TerminalResolve_CarriesExecutionIdAndSourceScript() + { + // The terminal Delivered notification must also carry the threaded + // provenance so the CachedResolve audit row is correlated. + var executionId = Guid.NewGuid(); + _service.RegisterDeliveryHandler(StoreAndForwardCategory.CachedDbWrite, + _ => Task.FromResult(true)); + + var trackedId = TrackedOperationId.New(); + await _service.EnqueueAsync( + StoreAndForwardCategory.CachedDbWrite, + "myDb", + """{"payload":"x"}""", + originInstanceName: "Plant.Tank", + maxRetries: 3, + retryInterval: TimeSpan.Zero, + attemptImmediateDelivery: false, + messageId: trackedId.ToString(), + executionId: executionId, + sourceScript: "Plant.Tank/OnAlarm"); + + await _service.RetryPendingMessagesAsync(); + + var notification = Assert.Single(_observer.Notifications); + Assert.Equal(CachedCallAttemptOutcome.Delivered, notification.Outcome); + Assert.Equal(executionId, notification.ExecutionId); + Assert.Equal("Plant.Tank/OnAlarm", notification.SourceScript); + } + // ── Best-effort contract: observer throws must NOT corrupt retry bookkeeping ── [Fact] diff --git a/tests/ScadaLink.StoreAndForward.Tests/StoreAndForwardStorageTests.cs b/tests/ScadaLink.StoreAndForward.Tests/StoreAndForwardStorageTests.cs index 13f103f..be5820e 100644 --- a/tests/ScadaLink.StoreAndForward.Tests/StoreAndForwardStorageTests.cs +++ b/tests/ScadaLink.StoreAndForward.Tests/StoreAndForwardStorageTests.cs @@ -293,6 +293,125 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.True(count >= 1); } + // ── Audit Log #23 (ExecutionId Task 4): execution_id / source_script ── + + [Fact] + public async Task EnqueueAsync_RoundTripsExecutionIdAndSourceScript() + { + // A cached call buffered on a transient failure carries the originating + // script execution's ExecutionId + SourceScript; both must survive a + // persist + read-back so the retry loop can stamp them on audit rows. + var executionId = Guid.NewGuid(); + var message = CreateMessage("exec1", StoreAndForwardCategory.ExternalSystem); + message.ExecutionId = executionId; + message.SourceScript = "Plant.Pump42/OnTick"; + + await _storage.EnqueueAsync(message); + + var retrieved = await _storage.GetMessageByIdAsync("exec1"); + Assert.NotNull(retrieved); + Assert.Equal(executionId, retrieved!.ExecutionId); + Assert.Equal("Plant.Pump42/OnTick", retrieved.SourceScript); + } + + [Fact] + public async Task EnqueueAsync_NullExecutionIdAndSourceScript_RoundTripAsNull() + { + // Non-cached-call enqueues (notifications) supply neither field — they + // must round-trip as null rather than throwing or coercing. + var message = CreateMessage("noexec1", StoreAndForwardCategory.Notification); + Assert.Null(message.ExecutionId); + Assert.Null(message.SourceScript); + + await _storage.EnqueueAsync(message); + + var retrieved = await _storage.GetMessageByIdAsync("noexec1"); + Assert.NotNull(retrieved); + Assert.Null(retrieved!.ExecutionId); + Assert.Null(retrieved.SourceScript); + } + + [Fact] + public async Task ExecutionIdAndSourceScript_SurviveRetrySweepRead() + { + // The retry sweep reads due rows via GetMessagesForRetryAsync; the new + // fields must be present on that read path too (it is the path that + // feeds the CachedCallAttemptContext). + var executionId = Guid.NewGuid(); + var message = CreateMessage("sweep1", StoreAndForwardCategory.CachedDbWrite); + message.ExecutionId = executionId; + message.SourceScript = "Plant.Tank/OnAlarm"; + message.LastAttemptAt = null; // due immediately + await _storage.EnqueueAsync(message); + + var due = await _storage.GetMessagesForRetryAsync(); + + var row = Assert.Single(due, m => m.Id == "sweep1"); + Assert.Equal(executionId, row.ExecutionId); + Assert.Equal("Plant.Tank/OnAlarm", row.SourceScript); + } + + [Fact] + public async Task LegacyRowWithoutNewColumns_ReadsBackAsNull() + { + // Back-compat: a row persisted by a build that pre-dates the + // execution_id / source_script columns must still deserialize, with + // ExecutionId / SourceScript reading back as null. Simulate the legacy + // schema by dropping the table and recreating it without the columns, + // inserting directly, then running InitializeAsync (which ALTER-adds + // the columns) and reading the row back. + await using (var setup = new SqliteConnection($"Data Source={_dbName};Mode=Memory;Cache=Shared")) + { + await setup.OpenAsync(); + await using var drop = setup.CreateCommand(); + drop.CommandText = @" + DROP TABLE IF EXISTS sf_messages; + CREATE TABLE sf_messages ( + id TEXT PRIMARY KEY, + category INTEGER NOT NULL, + target TEXT NOT NULL, + payload_json TEXT NOT NULL, + retry_count INTEGER NOT NULL DEFAULT 0, + max_retries INTEGER NOT NULL DEFAULT 50, + retry_interval_ms INTEGER NOT NULL DEFAULT 30000, + created_at TEXT NOT NULL, + last_attempt_at TEXT, + status INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + origin_instance TEXT + ); + INSERT INTO sf_messages (id, category, target, payload_json, created_at, status) + VALUES ('legacy1', 0, 'ERP', '{}', '2026-01-01T00:00:00.0000000+00:00', 0);"; + await drop.ExecuteNonQueryAsync(); + } + + // InitializeAsync must additively ALTER-in the new columns without + // disturbing the pre-existing legacy row. + await _storage.InitializeAsync(); + + var retrieved = await _storage.GetMessageByIdAsync("legacy1"); + Assert.NotNull(retrieved); + Assert.Equal("legacy1", retrieved!.Id); + Assert.Null(retrieved.ExecutionId); + Assert.Null(retrieved.SourceScript); + } + + [Fact] + public async Task InitializeAsync_IsIdempotent_WhenColumnsAlreadyExist() + { + // The additive ALTER must not fail on a second InitializeAsync call + // (SQLite has no ADD COLUMN IF NOT EXISTS — the probe must skip it). + await _storage.InitializeAsync(); + await _storage.InitializeAsync(); + + var message = CreateMessage("idem1", StoreAndForwardCategory.ExternalSystem); + message.ExecutionId = Guid.NewGuid(); + await _storage.EnqueueAsync(message); + var retrieved = await _storage.GetMessageByIdAsync("idem1"); + Assert.NotNull(retrieved); + Assert.Equal(message.ExecutionId, retrieved!.ExecutionId); + } + private static StoreAndForwardMessage CreateMessage(string id, StoreAndForwardCategory category) { return new StoreAndForwardMessage From 705ae95404e879dd419f816bdbef5159e621408d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 15:27:37 -0400 Subject: [PATCH 08/15] test(auditlog): assert ExecutionId threading hops; defensive Guid parse on S&F read --- .../StoreAndForwardStorage.cs | 23 ++++++++++- .../DatabaseGatewayTests.cs | 34 ++++++++++++---- .../ExternalSystemClientTests.cs | 35 ++++++++++++---- .../DatabaseCachedWriteEmissionTests.cs | 40 +++++++++++++++++++ .../ExternalSystemCachedCallEmissionTests.cs | 40 +++++++++++++++++++ .../StoreAndForwardStorageTests.cs | 40 +++++++++++++++++++ 6 files changed, 195 insertions(+), 17 deletions(-) diff --git a/src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs b/src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs index 7b92655..f7564fa 100644 --- a/src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs +++ b/src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs @@ -497,10 +497,31 @@ public class StoreAndForwardStorage // Audit Log #23 (ExecutionId Task 4): rows persisted before the // additive migration have no execution_id / source_script value; // IsDBNull guards keep those reading back as null (back-compat). - ExecutionId = reader.IsDBNull(12) ? null : Guid.Parse(reader.GetString(12)), + // Guid.TryParse (not Parse) guards the retry sweep: a corrupt + // non-null execution_id is treated as "no execution id" rather + // than throwing FormatException and aborting the whole sweep. + ExecutionId = ParseExecutionId(reader, 12), SourceScript = reader.IsDBNull(13) ? null : reader.GetString(13) }); } return results; } + + /// + /// Audit Log #23 (ExecutionId Task 4): defensively reads the + /// execution_id column. A null value (legacy pre-migration + /// rows) and a malformed non-null value both yield null — a corrupt + /// id must not throw and abort the retry sweep, which reads many rows. + /// + private static Guid? ParseExecutionId(System.Data.Common.DbDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) + { + return null; + } + + return Guid.TryParse(reader.GetString(ordinal), out var executionId) + ? executionId + : null; + } } diff --git a/tests/ScadaLink.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs b/tests/ScadaLink.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs index 3e8e796..36fbd9c 100644 --- a/tests/ScadaLink.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs +++ b/tests/ScadaLink.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs @@ -101,15 +101,27 @@ public class DatabaseGatewayTests var gateway = new DatabaseGateway(_repository, NullLogger.Instance, storeAndForward: sf); + // Audit Log #23 (ExecutionId Task 4): a known execution id / source + // script so the gateway -> EnqueueAsync hop can be asserted below. + var executionId = Guid.NewGuid(); + const string sourceScript = "ScriptActor:WriteAudit"; + await gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (@v)", - new Dictionary { ["v"] = 1 }); + new Dictionary { ["v"] = 1 }, + executionId: executionId, sourceScript: sourceScript); var depth = await storage.GetBufferDepthByCategoryAsync(); Assert.Equal(1, depth[ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.CachedDbWrite]); - var (maxRetries, retryIntervalMs) = ReadBufferedRetrySettings(connStr); - Assert.Equal(5, maxRetries); - Assert.Equal((long)TimeSpan.FromSeconds(12).TotalMilliseconds, retryIntervalMs); + var buffered = ReadBufferedRetrySettings(connStr); + Assert.Equal(5, buffered.MaxRetries); + Assert.Equal((long)TimeSpan.FromSeconds(12).TotalMilliseconds, buffered.RetryIntervalMs); + + // ExecutionId Task 4: the gateway must forward executionId / sourceScript + // into EnqueueAsync, and the S&F layer must persist them on the + // sf_messages row so the retry loop can stamp the right provenance. + Assert.Equal(executionId, buffered.ExecutionId); + Assert.Equal(sourceScript, buffered.SourceScript); } [Fact] @@ -148,21 +160,27 @@ public class DatabaseGatewayTests await gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (1)"); - var (maxRetries, _) = ReadBufferedRetrySettings(connStr); + var (maxRetries, _, _, _) = ReadBufferedRetrySettings(connStr); // Must be the bounded S&F default, never 0 — a stored 0 would mean retry-forever. Assert.Equal(99, maxRetries); Assert.NotEqual(0, maxRetries); } - private static (int MaxRetries, long RetryIntervalMs) ReadBufferedRetrySettings(string connStr) + private static (int MaxRetries, long RetryIntervalMs, Guid? ExecutionId, string? SourceScript) + ReadBufferedRetrySettings(string connStr) { using var conn = new Microsoft.Data.Sqlite.SqliteConnection(connStr); conn.Open(); using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT max_retries, retry_interval_ms FROM sf_messages"; + cmd.CommandText = + "SELECT max_retries, retry_interval_ms, execution_id, source_script FROM sf_messages"; using var reader = cmd.ExecuteReader(); Assert.True(reader.Read(), "expected exactly one buffered message"); - var result = (reader.GetInt32(0), reader.GetInt64(1)); + var result = ( + reader.GetInt32(0), + reader.GetInt64(1), + reader.IsDBNull(2) ? (Guid?)null : Guid.Parse(reader.GetString(2)), + reader.IsDBNull(3) ? null : reader.GetString(3)); Assert.False(reader.Read(), "expected exactly one buffered message"); return result; } diff --git a/tests/ScadaLink.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs b/tests/ScadaLink.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs index 43def9b..3898456 100644 --- a/tests/ScadaLink.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs +++ b/tests/ScadaLink.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs @@ -371,26 +371,45 @@ public class ExternalSystemClientTests _httpClientFactory, _repository, NullLogger.Instance, storeAndForward: sf); - var result = await client.CachedCallAsync("TestAPI", "postData"); + // Audit Log #23 (ExecutionId Task 4): a known execution id / source + // script so the gateway -> EnqueueAsync hop can be asserted below. + var executionId = Guid.NewGuid(); + const string sourceScript = "ScriptActor:CheckPressure"; + + var result = await client.CachedCallAsync( + "TestAPI", "postData", + executionId: executionId, sourceScript: sourceScript); Assert.True(result.WasBuffered); var depth = await storage.GetBufferDepthByCategoryAsync(); Assert.Equal(1, depth[ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.ExternalSystem]); - var (maxRetries, retryIntervalMs) = ReadBufferedRetrySettings(connStr); - Assert.Equal(7, maxRetries); - Assert.Equal((long)TimeSpan.FromSeconds(42).TotalMilliseconds, retryIntervalMs); + var buffered = ReadBufferedRetrySettings(connStr); + Assert.Equal(7, buffered.MaxRetries); + Assert.Equal((long)TimeSpan.FromSeconds(42).TotalMilliseconds, buffered.RetryIntervalMs); + + // ExecutionId Task 4: the gateway must forward executionId / sourceScript + // into EnqueueAsync, and the S&F layer must persist them on the + // sf_messages row so the retry loop can stamp the right provenance. + Assert.Equal(executionId, buffered.ExecutionId); + Assert.Equal(sourceScript, buffered.SourceScript); } - private static (int MaxRetries, long RetryIntervalMs) ReadBufferedRetrySettings(string connStr) + private static (int MaxRetries, long RetryIntervalMs, Guid? ExecutionId, string? SourceScript) + ReadBufferedRetrySettings(string connStr) { using var conn = new SqliteConnection(connStr); conn.Open(); using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT max_retries, retry_interval_ms FROM sf_messages"; + cmd.CommandText = + "SELECT max_retries, retry_interval_ms, execution_id, source_script FROM sf_messages"; using var reader = cmd.ExecuteReader(); Assert.True(reader.Read(), "expected exactly one buffered message"); - var result = (reader.GetInt32(0), reader.GetInt64(1)); + var result = ( + reader.GetInt32(0), + reader.GetInt64(1), + reader.IsDBNull(2) ? (Guid?)null : Guid.Parse(reader.GetString(2)), + reader.IsDBNull(3) ? null : reader.GetString(3)); Assert.False(reader.Read(), "expected exactly one buffered message"); return result; } @@ -436,7 +455,7 @@ public class ExternalSystemClientTests await client.CachedCallAsync("TestAPI", "postData"); - var (maxRetries, _) = ReadBufferedRetrySettings(connStr); + var (maxRetries, _, _, _) = ReadBufferedRetrySettings(connStr); // Must be the bounded S&F default, never 0 — a stored 0 would mean retry-forever. Assert.Equal(99, maxRetries); Assert.NotEqual(0, maxRetries); diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs index d4a9b2f..405b38e 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs @@ -155,6 +155,46 @@ public class DatabaseCachedWriteEmissionTests Times.Once); } + /// + /// Audit Log #23 (ExecutionId Task 4): the helper → gateway hop of the + /// threading chain. The cached-write helper must forward the runtime + /// context's ExecutionId and SourceScript verbatim into + /// — so the buffered retry + /// loop later stamps the right provenance onto its audit rows. This + /// asserts the exact id/script (not It.IsAny), so a regression that + /// dropped the threading would fail here. + /// + [Fact] + public async Task CachedWrite_ThreadsExecutionIdAndSourceScript_IntoGateway() + { + var gateway = new Mock(); + gateway + .Setup(g => g.CachedWriteAsync( + "myDb", "INSERT INTO t VALUES (1)", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + var forwarder = new CapturingForwarder(); + + var helper = CreateHelper(gateway.Object, forwarder); + await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)"); + + // The known TestExecutionId and SourceScript must reach the gateway + // unchanged — these are what the S&F retry loop persists and replays. + gateway.Verify(g => g.CachedWriteAsync( + "myDb", "INSERT INTO t VALUES (1)", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.Is(id => id == TestExecutionId), + It.Is(s => s == SourceScript)), + Times.Once); + } + [Fact] public async Task CachedWrite_ForwarderThrows_StillReturnsTrackedOperationId() { diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs index 8f7dde9..ce392cb 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs @@ -188,6 +188,46 @@ public class ExternalSystemCachedCallEmissionTests Times.Once); } + /// + /// Audit Log #23 (ExecutionId Task 4): the helper → gateway hop of the + /// threading chain. The cached-call helper must forward the runtime + /// context's ExecutionId and SourceScript verbatim into + /// — so the buffered + /// retry loop later stamps the right provenance onto its audit rows. + /// This asserts the exact id/script (not It.IsAny), so a regression + /// that dropped the threading would fail here. + /// + [Fact] + public async Task CachedCall_ThreadsExecutionIdAndSourceScript_IntoClient() + { + var client = new Mock(); + client + .Setup(c => c.CachedCallAsync( + "ERP", "GetOrder", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); + var forwarder = new CapturingForwarder(); + + var helper = CreateHelper(client.Object, forwarder); + await helper.CachedCall("ERP", "GetOrder"); + + // The known TestExecutionId and SourceScript must reach the client + // unchanged — these are what the S&F retry loop persists and replays. + client.Verify(c => c.CachedCallAsync( + "ERP", "GetOrder", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.Is(id => id == TestExecutionId), + It.Is(s => s == SourceScript)), + Times.Once); + } + [Fact] public async Task CachedCall_ForwarderThrows_StillReturnsTrackedOperationId_OriginalCallProceeds() { diff --git a/tests/ScadaLink.StoreAndForward.Tests/StoreAndForwardStorageTests.cs b/tests/ScadaLink.StoreAndForward.Tests/StoreAndForwardStorageTests.cs index be5820e..6fed58e 100644 --- a/tests/ScadaLink.StoreAndForward.Tests/StoreAndForwardStorageTests.cs +++ b/tests/ScadaLink.StoreAndForward.Tests/StoreAndForwardStorageTests.cs @@ -396,6 +396,46 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Null(retrieved.SourceScript); } + [Fact] + public async Task MalformedExecutionId_ReadsBackAsNull_DoesNotAbortRetrySweep() + { + // Defensive read path: a corrupt (non-null, non-GUID) execution_id must + // be treated as "no execution id" rather than throwing FormatException + // — a single bad row must not abort the whole GetMessagesForRetryAsync + // sweep, which reads many rows. Persist two due rows, then corrupt the + // execution_id of one directly in the DB. + var goodId = Guid.NewGuid(); + var good = CreateMessage("good1", StoreAndForwardCategory.ExternalSystem); + good.ExecutionId = goodId; + good.LastAttemptAt = null; // due immediately + await _storage.EnqueueAsync(good); + + var bad = CreateMessage("bad1", StoreAndForwardCategory.ExternalSystem); + bad.ExecutionId = Guid.NewGuid(); + bad.LastAttemptAt = null; // due immediately + await _storage.EnqueueAsync(bad); + + await using (var conn = new SqliteConnection($"Data Source={_dbName};Mode=Memory;Cache=Shared")) + { + await conn.OpenAsync(); + await using var corrupt = conn.CreateCommand(); + corrupt.CommandText = + "UPDATE sf_messages SET execution_id = 'not-a-guid' WHERE id = 'bad1';"; + await corrupt.ExecuteNonQueryAsync(); + } + + // The sweep must not throw; the corrupt row reads back with a null + // ExecutionId, the well-formed row keeps its value. + var due = await _storage.GetMessagesForRetryAsync(); + Assert.Null(Assert.Single(due, m => m.Id == "bad1").ExecutionId); + Assert.Equal(goodId, Assert.Single(due, m => m.Id == "good1").ExecutionId); + + // The single-row read path is equally defensive. + var retrieved = await _storage.GetMessageByIdAsync("bad1"); + Assert.NotNull(retrieved); + Assert.Null(retrieved!.ExecutionId); + } + [Fact] public async Task InitializeAsync_IsIdempotent_WhenColumnsAlreadyExist() { From 85bb61a1f330dce1c64b2f7aaffdd7ded22a387d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 15:35:40 -0400 Subject: [PATCH 09/15] feat(auditlog): NotifyDeliver rows carry the originating ExecutionId --- .../Entities/Notifications/Notification.cs | 9 + .../Notification/NotificationMessages.cs | 10 +- .../NotificationOutboxConfiguration.cs | 4 + ...dNotificationOriginExecutionId.Designer.cs | 1629 +++++++++++++++++ ...193048_AddNotificationOriginExecutionId.cs | 41 + .../ScadaLinkDbContextModelSnapshot.cs | 3 + .../NotificationOutboxActor.cs | 13 + .../Scripts/ScriptRuntimeContext.cs | 7 +- .../Entities/NotificationEntityTests.cs | 15 + .../Messages/NotificationMessagesTests.cs | 41 + ...ficationOriginExecutionIdMigrationTests.cs | 70 + .../RepositoryCoverageTests.cs | 23 + ...ficationOutboxActorAttemptEmissionTests.cs | 47 +- .../NotificationOutboxActorIngestTests.cs | 42 +- ...icationOutboxActorTerminalEmissionTests.cs | 48 +- .../Scripts/NotifyHelperTests.cs | 26 +- 16 files changed, 2020 insertions(+), 8 deletions(-) create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260521193048_AddNotificationOriginExecutionId.Designer.cs create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260521193048_AddNotificationOriginExecutionId.cs create mode 100644 tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationOriginExecutionIdMigrationTests.cs diff --git a/src/ScadaLink.Commons/Entities/Notifications/Notification.cs b/src/ScadaLink.Commons/Entities/Notifications/Notification.cs index ab94f56..916ea02 100644 --- a/src/ScadaLink.Commons/Entities/Notifications/Notification.cs +++ b/src/ScadaLink.Commons/Entities/Notifications/Notification.cs @@ -27,6 +27,15 @@ public class Notification public string SourceSiteId { get; set; } public string? SourceInstanceId { get; set; } public string? SourceScript { get; set; } + + /// + /// The originating script execution's ExecutionId (Audit Log #23). Carried from + /// the site on the so the + /// central dispatcher can stamp the same id onto its NotifyDeliver audit rows, + /// correlating them with the site-emitted NotifySend row. Null for notifications + /// submitted before the column existed, or raised outside a script-execution context. + /// + public Guid? OriginExecutionId { get; set; } public DateTimeOffset SiteEnqueuedAt { get; set; } /// Central ingest time. diff --git a/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs b/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs index 718e1b6..ecfb1d6 100644 --- a/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs +++ b/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs @@ -4,6 +4,13 @@ namespace ScadaLink.Commons.Messages.Notification; /// Site -> Central: submit a notification for central delivery. /// Fire-and-forget with ack; the site retries until a is received. /// +/// +/// The originating script execution's ExecutionId (Audit Log #23). Stamped at +/// Notify.Send time and carried, inside the serialized payload, through the site +/// store-and-forward buffer so the central dispatcher can echo it onto the +/// NotifyDeliver audit rows. Additive trailing member — null for messages built +/// before the field existed, or for notifications raised outside a script execution. +/// public record NotificationSubmit( string NotificationId, string ListName, @@ -12,7 +19,8 @@ public record NotificationSubmit( string SourceSiteId, string? SourceInstanceId, string? SourceScript, - DateTimeOffset SiteEnqueuedAt); + DateTimeOffset SiteEnqueuedAt, + Guid? OriginExecutionId = null); /// /// Central -> Site: ack sent after the notification row is persisted. diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs index 4009c56..a30859b 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs @@ -47,6 +47,10 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration n.SourceScript).HasMaxLength(200); + // OriginExecutionId (Audit Log #23): nullable uniqueidentifier carried from the + // site so the dispatcher can echo it onto NotifyDeliver audit rows. No index — + // it is never a query predicate on this table, only copied onto audit events. + builder.HasIndex(n => new { n.Status, n.NextAttemptAt }); builder.HasIndex(n => new { n.SourceSiteId, n.CreatedAt }); diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260521193048_AddNotificationOriginExecutionId.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521193048_AddNotificationOriginExecutionId.Designer.cs new file mode 100644 index 0000000..618ca79 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521193048_AddNotificationOriginExecutionId.Designer.cs @@ -0,0 +1,1629 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ScadaLink.ConfigurationDatabase; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaLinkDbContext))] + [Migration("20260521193048_AddNotificationOriginExecutionId")] + partial class AddNotificationOriginExecutionId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditEvent", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Actor") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMs") + .HasColumnType("int"); + + b.Property("ErrorDetail") + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorMessage") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Extra") + .HasColumnType("nvarchar(max)"); + + b.Property("ForwardState") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("PayloadTruncated") + .HasColumnType("bit"); + + b.Property("RequestSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceInstanceId") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceScript") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceSiteId") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_AuditLog_CorrelationId") + .HasFilter("[CorrelationId] IS NOT NULL"); + + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_AuditLog_EventId"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_AuditLog_Execution") + .HasFilter("[ExecutionId] IS NOT NULL"); + + b.HasIndex("OccurredAtUtc") + .IsDescending() + .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); + + b.HasIndex("SourceSiteId", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + b.HasIndex("Target", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Target_Occurred") + .HasFilter("[Target] IS NOT NULL"); + + b.HasIndex("Channel", "Status", "OccurredAtUtc") + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + b.ToTable("AuditLog", (string)null); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.SiteCall", b => + { + b.Property("TrackedOperationId") + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SourceSite") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("TerminalAtUtc") + .HasColumnType("datetime2"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("TrackedOperationId"); + + b.HasIndex("SourceSite", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Source_Created"); + + b.HasIndex("Status", "UpdatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Status_Updated"); + + b.ToTable("SiteCalls", (string)null); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedApiKeyIds") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.Notification", b => + { + b.Property("NotificationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ListName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NextAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("OriginExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResolvedTargets") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SiteEnqueuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceInstanceId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceScript") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceSiteId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TypeData") + .HasColumnType("nvarchar(max)"); + + b.HasKey("NotificationId"); + + b.HasIndex("SourceSiteId", "CreatedAt"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Admin" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Design" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployment" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployment" + }); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ScadaLink.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ScadaLink.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260521193048_AddNotificationOriginExecutionId.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521193048_AddNotificationOriginExecutionId.cs new file mode 100644 index 0000000..5174a57 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521193048_AddNotificationOriginExecutionId.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + /// + /// Adds the OriginExecutionId correlation column to the central + /// Notifications table (#21). It carries the originating script execution's + /// ExecutionId from the site so the dispatcher can echo it onto the + /// NotifyDeliver audit rows (#23), linking them to the site's NotifySend + /// row for the same run. + /// + /// The change is purely additive: OriginExecutionId uniqueidentifier NULL is + /// added with no default, so the operation is a metadata-only ALTER TABLE … ADD. + /// Unlike AuditLog, the Notifications table is NOT partitioned, so a + /// plain ADD is fine. No index is created — the column is never a query + /// predicate, only copied onto audit events. Historical rows stay NULL. + /// + public partial class AddNotificationOriginExecutionId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OriginExecutionId", + table: "Notifications", + type: "uniqueidentifier", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "OriginExecutionId", + table: "Notifications"); + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs index f69c4a4..ba22cb1 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs @@ -787,6 +787,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations b.Property("NextAttemptAt") .HasColumnType("datetimeoffset"); + b.Property("OriginExecutionId") + .HasColumnType("uniqueidentifier"); + b.Property("ResolvedTargets") .HasColumnType("nvarchar(max)"); diff --git a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs index fa61574..2c2af88 100644 --- a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs +++ b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs @@ -489,6 +489,10 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers /// parses the notification's id as a Guid; sites generate the id with /// Guid.NewGuid().ToString("N") so the parse always succeeds, but /// a non-Guid id is recorded as null rather than crashing the dispatcher. + /// is copied straight from + /// so the dispatcher's + /// NotifyDeliver rows carry the same per-run id as the site's + /// NotifySend row (Audit Log #23). /// private static AuditEvent BuildNotifyDeliverEvent( Notification notification, @@ -515,6 +519,12 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers SourceSiteId = notification.SourceSiteId, SourceInstanceId = notification.SourceInstanceId, SourceScript = notification.SourceScript, + // ExecutionId (Audit Log #23): the originating script execution's id, + // carried from the site on NotificationSubmit and persisted on the + // Notification row. Echoing it here links the central NotifyDeliver + // rows to the site-emitted NotifySend row for the same run. Null when + // the notification was raised outside a script execution. + ExecutionId = notification.OriginExecutionId, Target = notification.ListName, Status = status, ErrorMessage = errorMessage, @@ -941,6 +951,9 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers { SourceInstanceId = msg.SourceInstanceId, SourceScript = msg.SourceScript, + // OriginExecutionId (Audit Log #23): the originating script execution's id, + // carried from the site so the dispatcher can echo it onto NotifyDeliver rows. + OriginExecutionId = msg.OriginExecutionId, SiteEnqueuedAt = msg.SiteEnqueuedAt, CreatedAt = DateTimeOffset.UtcNow, // Status stays at its Pending default for the dispatch sweep to claim. diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs index 1d085f3..2b5df67 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -1407,7 +1407,12 @@ public class ScriptRuntimeContext // notification, threaded down from the script-execution context for the // central audit trail. Null when no single script owns the context. SourceScript: _sourceScript, - SiteEnqueuedAt: DateTimeOffset.UtcNow); + SiteEnqueuedAt: DateTimeOffset.UtcNow, + // OriginExecutionId (Audit Log #23): the SAME per-execution id stamped + // onto this run's NotifySend audit row. It rides inside the serialized + // payload through the S&F buffer to central, where the dispatcher echoes + // it onto the NotifyDeliver rows so all rows for one run share an id. + OriginExecutionId: _executionId); var payloadJson = JsonSerializer.Serialize(payload); diff --git a/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs b/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs index a0f593f..d3679da 100644 --- a/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs +++ b/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs @@ -21,6 +21,21 @@ public class NotificationEntityTests Assert.Equal("SiteA", n.SourceSiteId); } + [Fact] + public void OriginExecutionId_DefaultsToNull_AndIsSettable() + { + // Audit Log #23: OriginExecutionId carries the originating script + // execution's id from the site so the dispatcher can echo it onto + // NotifyDeliver rows. Null for notifications submitted before the + // column existed; settable from the NotificationSubmit message. + var n = new Notification("id-1", NotificationType.Email, "ops-team", "subj", "body", "SiteA"); + Assert.Null(n.OriginExecutionId); + + var executionId = Guid.NewGuid(); + n.OriginExecutionId = executionId; + Assert.Equal(executionId, n.OriginExecutionId); + } + [Fact] public void Constructor_NullArguments_Throw() { diff --git a/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs b/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs index f68996f..53b5a15 100644 --- a/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs +++ b/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs @@ -40,6 +40,47 @@ public class NotificationMessagesTests Assert.Null(msg.SourceScript); } + [Fact] + public void NotificationSubmit_OriginExecutionId_DefaultsToNull() + { + // Audit Log #23: OriginExecutionId is an additive trailing member — a + // submit built without it (old call sites / old serialized payloads) + // leaves the id null. + var msg = new NotificationSubmit( + "notif-3", "Operators", "Subject", "Body", + "site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow); + + Assert.Null(msg.OriginExecutionId); + } + + [Fact] + public void NotificationSubmit_OriginExecutionId_RoundTripsWhenSupplied() + { + var executionId = Guid.NewGuid(); + var msg = new NotificationSubmit( + "notif-4", "Operators", "Subject", "Body", + "site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow, executionId); + + Assert.Equal(executionId, msg.OriginExecutionId); + } + + [Fact] + public void NotificationSubmit_OriginExecutionId_SurvivesJsonRoundTrip() + { + // The buffered S&F payload IS a serialized NotificationSubmit; the + // forwarder deserializes it, so OriginExecutionId must survive JSON. + var executionId = Guid.NewGuid(); + var msg = new NotificationSubmit( + "notif-5", "Operators", "Subject", "Body", + "site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow, executionId); + + var json = System.Text.Json.JsonSerializer.Serialize(msg); + var roundTripped = System.Text.Json.JsonSerializer.Deserialize(json); + + Assert.NotNull(roundTripped); + Assert.Equal(executionId, roundTripped!.OriginExecutionId); + } + [Fact] public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch() { diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationOriginExecutionIdMigrationTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationOriginExecutionIdMigrationTests.cs new file mode 100644 index 0000000..d7ce92b --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationOriginExecutionIdMigrationTests.cs @@ -0,0 +1,70 @@ +using Xunit; + +namespace ScadaLink.ConfigurationDatabase.Tests.Migrations; + +/// +/// Audit Log #23 (ExecutionId Task 5) integration test for the +/// AddNotificationOriginExecutionId migration: applies the EF migrations +/// to a freshly-created MSSQL test database on the running infra/mssql container +/// and asserts that the Notifications table carries the new +/// OriginExecutionId column as a nullable uniqueidentifier. +/// +/// +/// Unlike AuditLog, the Notifications table is not partitioned, so +/// the column is a plain metadata-only ALTER TABLE … ADD with no index. +/// Tests pair with Skip.IfNot(...) so +/// the runner reports them as Skipped (not Passed) when MSSQL is unreachable. The +/// fixture applies the migrations once at construction time. +/// +public class AddNotificationOriginExecutionIdMigrationTests : IClassFixture +{ + private readonly MsSqlMigrationFixture _fixture; + + public AddNotificationOriginExecutionIdMigrationTests(MsSqlMigrationFixture fixture) + { + _fixture = fixture; + } + + [SkippableFact] + public async Task AppliesMigration_AddsOriginExecutionIdColumn_ToNotifications() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var present = await ScalarAsync( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginExecutionId' " + + "AND TABLE_SCHEMA = 'dbo';"); + Assert.Equal(1, present); + } + + [SkippableFact] + public async Task OriginExecutionIdColumn_IsNullableUniqueIdentifier() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var dataType = await ScalarAsync( + "SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginExecutionId';"); + Assert.Equal("uniqueidentifier", dataType); + + var isNullable = await ScalarAsync( + "SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginExecutionId';"); + Assert.Equal("YES", isNullable); + } + + // --- helpers ------------------------------------------------------------ + + private async Task ScalarAsync(string sql) + { + await using var conn = _fixture.OpenConnection(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + var result = await cmd.ExecuteScalarAsync(); + if (result is null || result is DBNull) + { + return default!; + } + return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!; + } +} diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs index 6f0d11b..20fdd50 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs @@ -268,6 +268,7 @@ public class NotificationOutboxConfigurationTests : IDisposable var lastAttemptAt = new DateTimeOffset(2026, 5, 19, 8, 1, 0, TimeSpan.Zero); var nextAttemptAt = new DateTimeOffset(2026, 5, 19, 8, 2, 0, TimeSpan.Zero); var deliveredAt = new DateTimeOffset(2026, 5, 19, 8, 3, 0, TimeSpan.Zero); + var originExecutionId = Guid.NewGuid(); var notification = new Notification(id, NotificationType.Email, "Ops List", "High Tank Level", "Tank 4 exceeded the high level threshold.", "site-north") @@ -279,6 +280,7 @@ public class NotificationOutboxConfigurationTests : IDisposable ResolvedTargets = "ops@example.test;duty@example.test", SourceInstanceId = "instance-42", SourceScript = "TankLevelAlarm", + OriginExecutionId = originExecutionId, SiteEnqueuedAt = siteEnqueuedAt, CreatedAt = createdAt, LastAttemptAt = lastAttemptAt, @@ -311,6 +313,27 @@ public class NotificationOutboxConfigurationTests : IDisposable Assert.Equal(lastAttemptAt, loaded.LastAttemptAt); Assert.Equal(nextAttemptAt, loaded.NextAttemptAt); Assert.Equal(deliveredAt, loaded.DeliveredAt); + Assert.Equal(originExecutionId, loaded.OriginExecutionId); + } + + [Fact] + public async Task Notification_NullOriginExecutionId_RoundTripsAsNull() + { + // Audit Log #23: OriginExecutionId is an additive nullable column — + // notifications raised outside a script execution (or submitted before + // the column existed) persist and reload it as null. + var id = Guid.NewGuid().ToString(); + var notification = new Notification(id, NotificationType.Email, "Ops List", + "Subject", "Body", "site-north"); + + _context.Notifications.Add(notification); + await _context.SaveChangesAsync(); + _context.ChangeTracker.Clear(); + + var loaded = await _context.Notifications.FindAsync(id); + + Assert.NotNull(loaded); + Assert.Null(loaded!.OriginExecutionId); } [Fact] diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs index 8780d59..f594d88 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs @@ -94,7 +94,8 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit private static Notification MakeNotification( Guid? notificationId = null, string sourceSite = "site-1", - int retryCount = 0) + int retryCount = 0, + Guid? originExecutionId = null) { return new Notification( (notificationId ?? Guid.NewGuid()).ToString("D"), @@ -108,6 +109,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit CreatedAt = DateTimeOffset.UtcNow, SourceInstanceId = "instance-42", SourceScript = "AlarmScript", + OriginExecutionId = originExecutionId, }; } @@ -162,6 +164,49 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit }); } + [Fact] + public void Attempt_CarriesOriginExecutionId_AsExecutionId() + { + // Audit Log #23: the Attempted NotifyDeliver row must echo the + // notification's OriginExecutionId so all rows for one run share an id. + SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1)); + var executionId = Guid.NewGuid(); + var notification = MakeNotification(originExecutionId: executionId); + _outboxRepository.GetDueAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new[] { notification }); + var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com")); + var actor = CreateActor([adapter]); + + actor.Tell(InternalMessages.DispatchTick.Instance); + + AwaitAssert(() => + { + var attempted = EventsByStatus(AuditStatus.Attempted); + Assert.Single(attempted); + Assert.Equal(executionId, attempted[0].ExecutionId); + }); + } + + [Fact] + public void Attempt_NullOriginExecutionId_HasNullExecutionId() + { + SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1)); + var notification = MakeNotification(originExecutionId: null); + _outboxRepository.GetDueAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new[] { notification }); + var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com")); + var actor = CreateActor([adapter]); + + actor.Tell(InternalMessages.DispatchTick.Instance); + + AwaitAssert(() => + { + var attempted = EventsByStatus(AuditStatus.Attempted); + Assert.Single(attempted); + Assert.Null(attempted[0].ExecutionId); + }); + } + [Fact] public void Attempt_TransientFailure_EmitsEvent_StatusAttempted_ErrorMessageSet() { diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs index 123711d..1b8cbab 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs @@ -39,7 +39,8 @@ public class NotificationOutboxActorIngestTests : TestKit NullLogger.Instance))); } - private static NotificationSubmit MakeSubmit(string? notificationId = null) + private static NotificationSubmit MakeSubmit( + string? notificationId = null, Guid? originExecutionId = null) { return new NotificationSubmit( NotificationId: notificationId ?? Guid.NewGuid().ToString(), @@ -49,7 +50,8 @@ public class NotificationOutboxActorIngestTests : TestKit SourceSiteId: "site-1", SourceInstanceId: "instance-42", SourceScript: "AlarmScript", - SiteEnqueuedAt: new DateTimeOffset(2026, 5, 19, 8, 30, 0, TimeSpan.Zero)); + SiteEnqueuedAt: new DateTimeOffset(2026, 5, 19, 8, 30, 0, TimeSpan.Zero), + OriginExecutionId: originExecutionId); } [Fact] @@ -83,6 +85,42 @@ public class NotificationOutboxActorIngestTests : TestKit Arg.Any()); } + [Fact] + public void NotificationSubmit_CopiesOriginExecutionId_OntoPersistedNotification() + { + // Audit Log #23: the originating script execution's id rides on the + // NotificationSubmit and must be persisted on the Notification row so + // the dispatcher can later echo it onto NotifyDeliver audit rows. + _repository.InsertIfNotExistsAsync(Arg.Any(), Arg.Any()) + .Returns(true); + var executionId = Guid.NewGuid(); + var submit = MakeSubmit(originExecutionId: executionId); + var actor = CreateActor(); + + actor.Tell(submit, TestActor); + + ExpectMsg(); + _repository.Received(1).InsertIfNotExistsAsync( + Arg.Is(n => n.OriginExecutionId == executionId), + Arg.Any()); + } + + [Fact] + public void NotificationSubmit_NullOriginExecutionId_PersistsNull() + { + _repository.InsertIfNotExistsAsync(Arg.Any(), Arg.Any()) + .Returns(true); + var submit = MakeSubmit(originExecutionId: null); + var actor = CreateActor(); + + actor.Tell(submit, TestActor); + + ExpectMsg(); + _repository.Received(1).InsertIfNotExistsAsync( + Arg.Is(n => n.OriginExecutionId == null), + Arg.Any()); + } + [Fact] public void DuplicateSubmit_RepositoryReturnsFalse_StillAcksAccepted() { diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs index eab39ac..74e0df8 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs @@ -87,7 +87,8 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit private static Notification MakeNotification( NotificationStatus status = NotificationStatus.Pending, int retryCount = 0, - Guid? notificationId = null) + Guid? notificationId = null, + Guid? originExecutionId = null) { return new Notification( (notificationId ?? Guid.NewGuid()).ToString("D"), @@ -100,6 +101,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit Status = status, RetryCount = retryCount, CreatedAt = DateTimeOffset.UtcNow, + OriginExecutionId = originExecutionId, }; } @@ -145,6 +147,50 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit }); } + [Fact] + public void Terminal_Delivered_CarriesOriginExecutionId_AsExecutionId() + { + // Audit Log #23: the terminal NotifyDeliver row must echo the + // notification's OriginExecutionId so it shares the per-run id with + // the site-emitted NotifySend row. + SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1)); + var executionId = Guid.NewGuid(); + var notification = MakeNotification(originExecutionId: executionId); + _outboxRepository.GetDueAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new[] { notification }); + var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com")); + var actor = CreateActor([adapter]); + + actor.Tell(InternalMessages.DispatchTick.Instance); + + AwaitAssert(() => + { + var delivered = EventsByStatus(AuditStatus.Delivered); + Assert.Single(delivered); + Assert.Equal(executionId, delivered[0].ExecutionId); + }); + } + + [Fact] + public void Terminal_Delivered_NullOriginExecutionId_HasNullExecutionId() + { + SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1)); + var notification = MakeNotification(originExecutionId: null); + _outboxRepository.GetDueAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new[] { notification }); + var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com")); + var actor = CreateActor([adapter]); + + actor.Tell(InternalMessages.DispatchTick.Instance); + + AwaitAssert(() => + { + var delivered = EventsByStatus(AuditStatus.Delivered); + Assert.Single(delivered); + Assert.Null(delivered[0].ExecutionId); + }); + } + [Fact] public void Terminal_Parked_OnPermanentFailure_EmitsEvent_StatusParked() { diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs index adfec68..bfb66f6 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs @@ -60,7 +60,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable private ScriptRuntimeContext.NotifyHelper CreateHelper( IActorRef siteCommunicationActor, - string? sourceScript = null) + string? sourceScript = null, + Guid? executionId = null) { return new ScriptRuntimeContext.NotifyHelper( _saf, @@ -70,7 +71,7 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable sourceScript, TimeSpan.FromSeconds(3), NullLogger.Instance, - Guid.NewGuid()); + executionId ?? Guid.NewGuid()); } [Fact] @@ -134,6 +135,27 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable Assert.Equal("ScriptActor:MonitorSpeed", payload!.SourceScript); } + [Fact] + public async Task Send_StampsExecutionId_OnTheNotificationSubmitPayload() + { + // Audit Log #23 (ExecutionId Task 5): Notify.Send must stamp the + // script run's ExecutionId onto the NotificationSubmit so it rides + // inside the serialized S&F payload to central, where the dispatcher + // echoes it onto the NotifyDeliver rows. This is the SAME id stamped + // onto the site-emitted NotifySend audit row. + var executionId = Guid.NewGuid(); + var commProbe = CreateTestProbe(); + var notify = CreateHelper(commProbe.Ref, executionId: executionId); + + var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped"); + + var buffered = await _saf.GetMessageByIdAsync(notificationId); + Assert.NotNull(buffered); + var payload = JsonSerializer.Deserialize(buffered!.PayloadJson); + Assert.NotNull(payload); + Assert.Equal(executionId, payload!.OriginExecutionId); + } + [Fact] public async Task Send_WhenHelperHasNoSourceScript_LeavesSourceScriptNull() { From 6aac4c8ed725d36a6b554627bbb4914f3f33a3f6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 15:42:45 -0400 Subject: [PATCH 10/15] test(auditlog): pin OriginExecutionId preservation in forwarder + Parked NotifyDeliver --- ...icationOutboxActorTerminalEmissionTests.cs | 25 ++++++++++++++ .../NotificationForwarderTests.cs | 33 +++++++++++++++++-- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs index 74e0df8..08c8cd1 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs @@ -212,6 +212,31 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit }); } + [Fact] + public void Terminal_Parked_CarriesOriginExecutionId_AsExecutionId() + { + // Audit Log #23: the Parked terminal NotifyDeliver row flows through the + // same BuildNotifyDeliverEvent path as the Delivered row, so it must + // likewise echo the notification's OriginExecutionId — sharing the + // per-run id with the site-emitted NotifySend row. + SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1)); + var executionId = Guid.NewGuid(); + var notification = MakeNotification(originExecutionId: executionId); + _outboxRepository.GetDueAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new[] { notification }); + var adapter = new StubAdapter(() => DeliveryOutcome.Permanent("invalid recipient address")); + var actor = CreateActor([adapter]); + + actor.Tell(InternalMessages.DispatchTick.Instance); + + AwaitAssert(() => + { + var parked = EventsByStatus(AuditStatus.Parked); + Assert.Single(parked); + Assert.Equal(executionId, parked[0].ExecutionId); + }); + } + [Fact] public void Terminal_Parked_OnTransientReachingMaxRetries_EmitsEvent_StatusParked() { diff --git a/tests/ScadaLink.StoreAndForward.Tests/NotificationForwarderTests.cs b/tests/ScadaLink.StoreAndForward.Tests/NotificationForwarderTests.cs index 822fe33..5fa79f4 100644 --- a/tests/ScadaLink.StoreAndForward.Tests/NotificationForwarderTests.cs +++ b/tests/ScadaLink.StoreAndForward.Tests/NotificationForwarderTests.cs @@ -26,7 +26,8 @@ public class NotificationForwarderTests : TestKit private static StoreAndForwardMessage BufferedNotification( string id = "msg-1", string listName = "Operators", string subject = "Pump alarm", string message = "Pump 3 tripped", - string? originInstance = "Plant.Pump3", string? sourceScript = "alarmScript") + string? originInstance = "Plant.Pump3", string? sourceScript = "alarmScript", + Guid? originExecutionId = null) { var payload = JsonSerializer.Serialize(new NotificationSubmit( NotificationId: id, @@ -37,7 +38,8 @@ public class NotificationForwarderTests : TestKit SourceSiteId: string.Empty, SourceInstanceId: originInstance, SourceScript: sourceScript, - SiteEnqueuedAt: DateTimeOffset.UtcNow)); + SiteEnqueuedAt: DateTimeOffset.UtcNow, + OriginExecutionId: originExecutionId)); return new StoreAndForwardMessage { Id = id, @@ -78,6 +80,33 @@ public class NotificationForwarderTests : TestKit Assert.True(await deliverTask); } + [Fact] + public async Task Deliver_PreservesOriginExecutionId_FromBufferedPayload() + { + // Audit Log #23: the buffered payload's OriginExecutionId is the per-run + // id stamped at Notify.Send time. The forwarder re-stamps only the four + // fields it authoritatively owns (NotificationId, ListName, SourceSiteId, + // SourceInstanceId) via the `with` expression — OriginExecutionId is + // preserved precisely BY being absent from that `with` block. This test + // pins that: if OriginExecutionId is ever added to the `with` expression + // (e.g. reset to null), the forwarded NotificationSubmit would lose the + // per-run id and central could not echo it onto NotifyDeliver rows. + var centralProbe = CreateTestProbe(); + var forwarder = new NotificationForwarder( + centralProbe.Ref, "site-7", ForwardTimeout); + + var executionId = Guid.NewGuid(); + var msg = BufferedNotification(id: "msg-exec", originExecutionId: executionId); + + var deliverTask = forwarder.DeliverAsync(msg); + + var submit = centralProbe.ExpectMsg(); + Assert.Equal(executionId, submit.OriginExecutionId); + centralProbe.Reply(new NotificationSubmitAck(submit.NotificationId, Accepted: true, Error: null)); + + Assert.True(await deliverTask); + } + [Fact] public async Task Deliver_FallsBackToTarget_WhenPayloadListNameIsEmpty() { From cfd8f1ecf4701f2016bb4887d0bda03a2104d9d8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 15:44:17 -0400 Subject: [PATCH 11/15] feat(auditlog): inbound audit rows carry ExecutionId --- .../Middleware/AuditWriteMiddleware.cs | 10 +++++++--- .../Middleware/AuditWriteMiddlewareTests.cs | 19 ++++++++++++------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs b/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs index c271d03..b4b8410 100644 --- a/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs +++ b/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs @@ -145,17 +145,21 @@ public sealed class AuditWriteMiddleware OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.ApiInbound, Kind = kind, - // Audit Log #23: a fresh per-request correlation id so the + // Audit Log #23: a fresh per-request execution id so the // inbound row carries a request identifier (closes the design // gap that inbound rows should be correlatable). // // This id is intentionally request-local: it is NOT bridged to // RouteHelper's routed-call correlation id or to // HttpContext.TraceIdentifier. Threading an inbound request's - // correlation id through to the routed script execution (so an + // execution id through to the routed script execution (so an // inbound call and the outbound API/DB rows it triggers share // one id) is a deliberate future follow-up, out of scope here. - CorrelationId = Guid.NewGuid(), + ExecutionId = Guid.NewGuid(), + // CorrelationId is purely the per-operation-lifecycle id; an + // inbound request is a one-shot from the audit row's + // perspective with no multi-row operation to correlate. + CorrelationId = null, Actor = actor, Target = methodName, Status = status, diff --git a/tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs b/tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs index 0bf8f0b..436507f 100644 --- a/tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs +++ b/tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs @@ -351,12 +351,14 @@ public class AuditWriteMiddlewareTests } // --------------------------------------------------------------------- - // Correlation id — Audit Log #23: each inbound row carries a fresh - // per-request correlation id so inbound rows are correlatable. + // Execution id — Audit Log #23: each inbound row carries a fresh + // per-request execution id so inbound rows are correlatable. The inbound + // row's CorrelationId stays null — CorrelationId is purely the + // per-operation-lifecycle id and an inbound request is a one-shot. // --------------------------------------------------------------------- [Fact] - public async Task InboundRow_CarriesNonNull_CorrelationId() + public async Task InboundRow_CarriesNonNull_ExecutionId_And_NullCorrelationId() { var writer = new RecordingAuditWriter(); var ctx = BuildContext(); @@ -369,12 +371,15 @@ public class AuditWriteMiddlewareTests await mw.InvokeAsync(ctx); var evt = Assert.Single(writer.Events); - Assert.NotNull(evt.CorrelationId); - Assert.NotEqual(Guid.Empty, evt.CorrelationId!.Value); + Assert.NotNull(evt.ExecutionId); + Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value); + // CorrelationId is the per-operation-lifecycle id; an inbound request + // is a one-shot with no multi-row operation to correlate. + Assert.Null(evt.CorrelationId); } [Fact] - public async Task SeparateRequests_GetDistinct_CorrelationIds() + public async Task SeparateRequests_GetDistinct_ExecutionIds() { var writer = new RecordingAuditWriter(); var mw = CreateMiddleware(hc => @@ -387,7 +392,7 @@ public class AuditWriteMiddlewareTests await mw.InvokeAsync(BuildContext()); Assert.Equal(2, writer.Events.Count); - Assert.NotEqual(writer.Events[0].CorrelationId, writer.Events[1].CorrelationId); + Assert.NotEqual(writer.Events[0].ExecutionId, writer.Events[1].ExecutionId); } [Fact] From 1ba62052d694744f47d6b0b151590594a13d0372 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 15:52:57 -0400 Subject: [PATCH 12/15] feat(centralui): ExecutionId column, filter and drill-in on the Audit Log page --- .../Audit/AuditDrilldownDrawer.razor | 11 ++++ .../Audit/AuditDrilldownDrawer.razor.cs | 21 ++++++- .../Components/Audit/AuditFilterBar.razor | 10 +++ .../Components/Audit/AuditFilterBar.razor.cs | 1 + .../Components/Audit/AuditQueryModel.cs | 15 +++++ .../Components/Audit/AuditResultsGrid.razor | 21 +++++++ .../Audit/AuditResultsGrid.razor.cs | 8 ++- .../Pages/Audit/AuditLogPage.razor.cs | 22 ++++++- .../Audit/AuditDataSeeder.cs | 10 +-- .../Audit/AuditLogPageTests.cs | 61 +++++++++++++++++++ .../Audit/AuditDrilldownDrawerTests.cs | 42 +++++++++++++ .../Components/Audit/AuditFilterBarTests.cs | 37 +++++++++++ .../Components/Audit/AuditResultsGridTests.cs | 46 +++++++++++++- .../Pages/AuditLogPageExportUrlTests.cs | 14 +++++ .../Pages/AuditLogPageScaffoldTests.cs | 38 ++++++++++++ 15 files changed, 343 insertions(+), 14 deletions(-) diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor index f580fb8..ff31789 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor @@ -55,6 +55,9 @@
CorrelationId
@(Event.CorrelationId?.ToString() ?? "—")
+
ExecutionId
+
@(Event.ExecutionId?.ToString() ?? "—")
+
OccurredAtUtc
@FormatTimestamp(Event.OccurredAtUtc)
@@ -151,6 +154,14 @@ Show all events for this operation } + @if (Event.ExecutionId is not null) + { + + }
public partial class AuditDrilldownDrawer @@ -276,6 +277,20 @@ public partial class AuditDrilldownDrawer Navigation.NavigateTo(uri); } + /// + /// Drill-in to every audit row sharing this row's + /// — the universal per-run correlation value, distinct from the per-operation + /// CorrelationId drill-back above. Navigates to /audit/log?executionId={id}, + /// which the page parses on init and auto-loads. The button is only rendered + /// when is non-null, so this is total. + /// + private void ViewThisExecution() + { + if (Event?.ExecutionId is not { } exec) return; + var uri = $"/audit/log?executionId={exec}"; + Navigation.NavigateTo(uri); + } + /// /// Build a cURL command from an audit event. The URL comes from /// Target; when the RequestSummary parses as diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor index 21f2fe1..6d4bb29 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor @@ -117,6 +117,16 @@ placeholder="contains…" @bind="_model.ActorSearch" /> + @* ExecutionId is an exact-match Guid filter — the operator pastes the + universal per-run correlation value. Lax-parsed in ToFilter so a + blank/malformed paste simply drops the constraint. *@ +
+ + +
+
+ /// Paste-in ExecutionId filter — the operator pastes the universal per-run + /// correlation Guid. Stored as free text; lax-parses it + /// through so a blank or + /// unparseable value simply yields no constraint. + ///
+ public string ExecutionId { get; set; } = string.Empty; + public bool ErrorsOnly { get; set; } /// @@ -114,6 +122,12 @@ public sealed class AuditQueryModel var (fromUtc, toUtc) = ResolveTimeWindow(utcNow); + // Lax-parse the pasted ExecutionId — blank or malformed text yields no + // constraint rather than an error, mirroring the optional-filter contract. + Guid? executionId = Guid.TryParse(ExecutionId, out var parsedExecutionId) + ? parsedExecutionId + : null; + return new AuditLogQueryFilter( Channels: Channels.Count > 0 ? Channels.ToArray() : null, Kinds: Kinds.Count > 0 ? Kinds.ToArray() : null, @@ -122,6 +136,7 @@ public sealed class AuditQueryModel Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(), Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(), CorrelationId: null, + ExecutionId: executionId, FromUtc: fromUtc, ToUtc: toUtc); } diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor index af2fb0d..2e5c692 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor @@ -83,6 +83,15 @@ @code { + // Compact display for Guid id columns: the first 8 hex digits, mirroring + // the drilldown drawer's ShortEventId presentation. The full value is kept + // in the cell's title attribute so it stays copy-paste accessible. + private static string ShortGuid(Guid value) + { + var n = value.ToString("N"); + return n.Length >= 8 ? n[..8] : n; + } + private RenderFragment RenderCell(string key, AuditEvent row) => __builder => { switch (key) @@ -111,6 +120,18 @@ case "Actor": @(row.Actor ?? "—") break; + case "ExecutionId": + @if (row.ExecutionId is { } executionId) + { + @ShortGuid(executionId) + } + else + { + + } + break; case "DurationMs": @(row.DurationMs?.ToString() ?? "—") break; diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs index d6b08c4..1303628 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs @@ -9,9 +9,10 @@ namespace ScadaLink.CentralUI.Components.Audit; /// /// Keyset-paged results grid for the central Audit Log page (#23 M7-T3). -/// Renders the 10 columns named in Component-AuditLog.md §10: -/// OccurredAtUtc, Site, Channel, Kind, Status, Target, Actor, DurationMs, -/// HttpStatus, ErrorMessage. Talks to +/// Renders the columns named in Component-AuditLog.md §10 — OccurredAtUtc, +/// Site, Channel, Kind, Status, Target, Actor, DurationMs, HttpStatus, +/// ErrorMessage — plus the ExecutionId per-run correlation column. Talks to +/// /// — never to IAuditLogRepository directly — so tests can stub the data /// source without standing up EF Core. /// @@ -121,6 +122,7 @@ public partial class AuditResultsGrid : IAsyncDisposable ("Status", "Status"), ("Target", "Target"), ("Actor", "Actor"), + ("ExecutionId", "ExecutionId"), ("DurationMs", "DurationMs"), ("HttpStatus", "HttpStatus"), ("ErrorMessage", "ErrorMessage"), diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs index bd2796e..9fb1d42 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs @@ -22,7 +22,8 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit; /// ?actor=, ?site=, ?channel=, ?kind=, and the UI-only /// ?instance= are read on initialization. Bundle E (M7-T13) extends /// this with ?status= so the Health-dashboard Audit error-rate tile can -/// drill in to ?status=Failed. When any param is present we allocate a +/// drill in to ?status=Failed. The ExecutionId follow-up adds +/// ?executionId= for the "View this execution" drill-in. When any param is present we allocate a /// fresh and assign it to /// , which kicks the results grid into auto-load /// without the user clicking Apply. Unknown values (e.g. an invalid enum name) @@ -60,6 +61,16 @@ public partial class AuditLogPage correlationId = parsedCorr; } + // ?executionId= is the "View this execution" drill-in target — the + // universal per-run correlation value. Lax-parsed like ?correlationId=: + // an unparseable value is silently dropped (no constraint). + Guid? executionId = null; + if (query.TryGetValue("executionId", out var execValues) + && Guid.TryParse(execValues.ToString(), out var parsedExec)) + { + executionId = parsedExec; + } + string? target = null; if (query.TryGetValue("target", out var targetValues)) { @@ -117,7 +128,7 @@ public partial class AuditLogPage // auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load // because the filter contract has no instance column — the user still needs // to refine + Apply for those. - if (correlationId is null && target is null && actor is null + if (correlationId is null && executionId is null && target is null && actor is null && sites is null && channels is null && kinds is null && statuses is null) { return; @@ -130,7 +141,8 @@ public partial class AuditLogPage SourceSiteIds: sites, Target: target, Actor: actor, - CorrelationId: correlationId); + CorrelationId: correlationId, + ExecutionId: executionId); } /// @@ -236,6 +248,10 @@ public partial class AuditLogPage { parts.Add(new("correlationId", corr.ToString())); } + if (filter.ExecutionId is { } exec) + { + parts.Add(new("executionId", exec.ToString())); + } if (filter.FromUtc is { } from) { parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture))); diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditDataSeeder.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditDataSeeder.cs index 31252af..4c2480d 100644 --- a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditDataSeeder.cs +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditDataSeeder.cs @@ -65,6 +65,7 @@ internal static class AuditDataSeeder string? target = null, string? actor = null, Guid? correlationId = null, + Guid? executionId = null, int? httpStatus = null, int? durationMs = null, string? errorMessage = null, @@ -76,13 +77,13 @@ internal static class AuditDataSeeder const string sql = @" INSERT INTO [AuditLog] ([EventId], [OccurredAtUtc], [IngestedAtUtc], [Channel], [Kind], [CorrelationId], - [SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target], [Status], - [HttpStatus], [DurationMs], [ErrorMessage], [ErrorDetail], [RequestSummary], + [ExecutionId], [SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target], + [Status], [HttpStatus], [DurationMs], [ErrorMessage], [ErrorDetail], [RequestSummary], [ResponseSummary], [PayloadTruncated], [Extra], [ForwardState]) VALUES (@eventId, @occurredAtUtc, SYSUTCDATETIME(), @channel, @kind, @correlationId, - @sourceSiteId, NULL, NULL, @actor, @target, @status, - @httpStatus, @durationMs, @errorMessage, NULL, @requestSummary, + @executionId, @sourceSiteId, NULL, NULL, @actor, @target, + @status, @httpStatus, @durationMs, @errorMessage, NULL, @requestSummary, @responseSummary, 0, @extra, NULL);"; await using var connection = new SqlConnection(ConnectionString); @@ -94,6 +95,7 @@ VALUES cmd.Parameters.AddWithValue("@channel", channel); cmd.Parameters.AddWithValue("@kind", kind); cmd.Parameters.AddWithValue("@correlationId", (object?)correlationId ?? DBNull.Value); + cmd.Parameters.AddWithValue("@executionId", (object?)executionId ?? DBNull.Value); cmd.Parameters.AddWithValue("@sourceSiteId", (object?)sourceSiteId ?? DBNull.Value); cmd.Parameters.AddWithValue("@actor", (object?)actor ?? DBNull.Value); cmd.Parameters.AddWithValue("@target", (object?)target ?? DBNull.Value); diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs index 68305d0..f066cdb 100644 --- a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs @@ -24,6 +24,9 @@ namespace ScadaLink.CentralUI.PlaywrightTests.Audit; /// link relies on; verified by reproducing the link target directly because /// seeding a notification visible to the report page requires the Akka query /// path, not just an INSERT). +/// DrillInFromExecutionId_LandsOnAuditLogWithFilterContext — the +/// ?executionId= drill-in (the drawer's "View this execution" action) +/// auto-loads the grid filtered by ExecutionId. /// NotificationsPage_HasViewAuditHistoryLink_WhenNotificationsExist — /// the report page wires drill-in links when notifications are present. /// ExportCsv_LinkIsVisibleAndDownloads — Export CSV button gated on @@ -289,6 +292,64 @@ public class AuditLogPageTests } } + [Fact] + public async Task DrillInFromExecutionId_LandsOnAuditLogWithFilterContext() + { + // Mirrors the correlationId drill-in: the "View this execution" drawer + // action navigates to /audit/log?executionId={ExecutionId}. We seed a row + // carrying that ExecutionId, hit the deep link directly, and assert the + // page deserializes the param and auto-loads the seeded row. + if (!await AuditDataSeeder.IsAvailableAsync()) + { + throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions."); + } + + var runId = Guid.NewGuid().ToString("N"); + var targetPrefix = $"playwright-test/exec-drill-in/{runId}/"; + var executionId = Guid.NewGuid(); + var eventId = Guid.NewGuid(); + var now = DateTime.UtcNow; + + try + { + await AuditDataSeeder.InsertAuditEventAsync( + eventId: eventId, + occurredAtUtc: now, + channel: "ApiOutbound", + kind: "ApiCall", + status: "Delivered", + target: targetPrefix + "endpoint", + executionId: executionId, + httpStatus: 200, + durationMs: 11); + + var page = await _fixture.NewAuthenticatedPageAsync(); + + // The exact URL the drawer's "View this execution" button produces. + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?executionId={executionId}"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + Assert.Contains($"executionId={executionId}", page.Url); + await Assertions.Expect(page.Locator("h1:has-text('Audit Log')")).ToBeVisibleAsync(); + await Assertions.Expect(page.Locator("[data-test='audit-filter-bar']")).ToBeVisibleAsync(); + await Assertions.Expect(page.Locator("[data-test='audit-results-grid']")).ToBeVisibleAsync(); + + // Auto-load: the query-string drill-in resolves the ?executionId= + // filter on OnInitialized and the seeded row appears without an + // Apply click. + var seededRow = page.Locator($"[data-test='grid-row-{eventId}']"); + await Assertions.Expect(seededRow).ToBeVisibleAsync(); + + // The ExecutionId column renders the row's short-form value. + var execCell = page.Locator($"[data-test='execution-id-{eventId}']"); + await Assertions.Expect(execCell).ToBeVisibleAsync(); + } + finally + { + await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix); + } + } + [Fact] public async Task NotificationsPage_RendersAuditDrillInLinkPattern() { diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs index 53c7c2d..b82d944 100644 --- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs @@ -40,6 +40,7 @@ public class AuditDrilldownDrawerTests : BunitContext string? responseSummary = null, string? extra = null, Guid? correlationId = null, + Guid? executionId = null, string? errorMessage = null, string? errorDetail = null, string? target = "demo-target") @@ -51,6 +52,7 @@ public class AuditDrilldownDrawerTests : BunitContext Channel = channel, Kind = kind, CorrelationId = correlationId, + ExecutionId = executionId, SourceSiteId = "plant-a", SourceInstanceId = "boiler-3", SourceScript = "OnAlarm.csx", @@ -216,6 +218,46 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.Contains(corr.ToString(), nav.Uri); } + [Fact] + public void Drawer_NullExecutionId_HidesViewThisExecutionButton() + { + var ev = MakeEvent(executionId: null); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + Assert.DoesNotContain("data-test=\"view-this-execution\"", cut.Markup); + } + + [Fact] + public void Drawer_NonNullExecutionId_ShowsViewThisExecutionButton() + { + var ev = MakeEvent(executionId: Guid.Parse("aaaaaaaa-1111-2222-3333-444444444444")); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + Assert.Contains("data-test=\"view-this-execution\"", cut.Markup); + } + + [Fact] + public void ViewThisExecution_Navigates_WithExecutionIdQueryString() + { + var exec = Guid.Parse("dddddddd-cccc-bbbb-aaaa-999999999999"); + var ev = MakeEvent(executionId: exec); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + cut.Find("[data-test=\"view-this-execution\"]").Click(); + + var nav = (BunitNavigationManager)Services.GetRequiredService(); + Assert.Contains($"/audit/log?executionId={exec}", nav.Uri); + } + [Fact] public async Task CopyAsCurl_InvokesClipboard_WithCurlString() { diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs index d62918d..3c3b202 100644 --- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs @@ -61,6 +61,7 @@ public class AuditFilterBarTests : BunitContext "data-test=\"filter-script\"", "data-test=\"filter-target\"", "data-test=\"filter-actor\"", + "data-test=\"filter-execution-id\"", "data-test=\"filter-errors-only\"", }; foreach (var marker in markers) @@ -178,6 +179,42 @@ public class AuditFilterBarTests : BunitContext Assert.Contains(AuditStatus.Failed, captured.Statuses); } + [Fact] + public void Apply_WithPastedExecutionId_MapsThroughToFilter() + { + // The operator pastes a Guid into the Execution ID box; Apply must map it + // straight onto AuditLogQueryFilter.ExecutionId. + var executionId = Guid.Parse("99999999-8888-7777-6666-555555555555"); + AuditLogQueryFilter? captured = null; + var cut = Render(p => p + .Add(c => c.OnFilterChanged, EventCallback.Factory.Create(this, f => captured = f))); + + cut.Find("[data-test=\"filter-execution-id\"] input").Change(executionId.ToString()); + cut.Find("[data-test=\"filter-apply\"]").Click(); + + Assert.NotNull(captured); + Assert.Equal(executionId, captured!.ExecutionId); + } + + [Fact] + public void Apply_WithBlankOrUnparseableExecutionId_LeavesFilterExecutionIdNull() + { + // Lax parsing: a blank box yields no constraint; garbage text likewise. + AuditLogQueryFilter? captured = null; + var cut = Render(p => p + .Add(c => c.OnFilterChanged, EventCallback.Factory.Create(this, f => captured = f))); + + // Blank — never typed into. + cut.Find("[data-test=\"filter-apply\"]").Click(); + Assert.NotNull(captured); + Assert.Null(captured!.ExecutionId); + + // Unparseable paste — still dropped, no error. + cut.Find("[data-test=\"filter-execution-id\"] input").Change("not-a-guid"); + cut.Find("[data-test=\"filter-apply\"]").Click(); + Assert.Null(captured!.ExecutionId); + } + [Fact] public void TimeRange_LastHour_PopulatesFromUtc_ApproxOneHourAgo() { diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs index ab30a70..4ba39d5 100644 --- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs @@ -22,7 +22,7 @@ public class AuditResultsGridTests : BunitContext private readonly IAuditLogQueryService _service; private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new(); - private static AuditEvent MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a") + private static AuditEvent MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a", Guid? executionId = null) => new() { EventId = Guid.NewGuid(), @@ -33,6 +33,7 @@ public class AuditResultsGridTests : BunitContext SourceSiteId = site, Target = "demo-target", Actor = "tester", + ExecutionId = executionId, DurationMs = 42, HttpStatus = status == AuditStatus.Delivered ? 200 : 500, ErrorMessage = status == AuditStatus.Failed ? "boom — unreachable" : null, @@ -121,6 +122,49 @@ public class AuditResultsGridTests : BunitContext Assert.Equal(target.EventId, captured!.EventId); } + [Fact] + public void Render_IncludesExecutionIdColumn() + { + StubPage(new List + { + MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered), + }); + + var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); + + // The ExecutionId column header is present alongside the spec columns. + Assert.Contains("data-test=\"col-header-ExecutionId\"", cut.Markup); + } + + [Fact] + public void ExecutionId_NonNullRow_RendersShortMonospaceValue() + { + var executionId = Guid.Parse("abcdef01-2222-3333-4444-555555555555"); + var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, executionId: executionId); + StubPage(new[] { row }); + + var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); + + var cell = cut.Find($"[data-test=\"execution-id-{row.EventId}\"]"); + // Short form: first 8 hex digits of the "N" form. + Assert.Equal("abcdef01", cell.TextContent.Trim()); + // Monospace presentation; full value retained in the title attribute. + Assert.Contains("font-monospace", cell.GetAttribute("class") ?? string.Empty); + Assert.Equal(executionId.ToString(), cell.GetAttribute("title")); + } + + [Fact] + public void ExecutionId_NullRow_RendersBlankPlaceholder_NoExecutionIdCell() + { + var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, executionId: null); + StubPage(new[] { row }); + + var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); + + // A null ExecutionId renders the em-dash placeholder, not a value cell. + Assert.Empty(cut.FindAll($"[data-test=\"execution-id-{row.EventId}\"]")); + } + [Fact] public void Status_FailedRow_HasErrorBadgeClass() { diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs index 60e3d56..02892a7 100644 --- a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs @@ -75,6 +75,20 @@ public class AuditLogPageExportUrlTests Assert.Equal("Notification", query["channel"]); } + [Fact] + public void BuildExportUrl_ExecutionIdSet_EmitsExecutionIdParam() + { + var exec = Guid.Parse("12121212-3434-5656-7878-909090909090"); + var filter = new AuditLogQueryFilter(ExecutionId: exec); + + var url = AuditLogPage.BuildExportUrl(filter); + + Assert.StartsWith("/api/centralui/audit/export?", url); + var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query); + Assert.Single(query); + Assert.Equal(exec.ToString(), query["executionId"]); + } + [Fact] public void BuildExportUrl_MultiValueDimensions_EmitRepeatedParams() { diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs index 328048e..55062dd 100644 --- a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs @@ -176,6 +176,44 @@ public class AuditLogPageScaffoldTests : BunitContext }); } + [Fact] + public void NavigateWithExecutionIdParam_AppliesFilter_AndAutoLoads() + { + // The "View this execution" drill-in lands on /audit/log?executionId={id}. + // The page parses the Guid, builds an AuditLogQueryFilter with ExecutionId + // set, and auto-loads the grid. + var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + _queryService = Substitute.For(); + _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(new List())); + + var cut = RenderAuditLogPageWithQuery($"executionId={executionId}", "Admin"); + + cut.WaitForAssertion(() => + { + _queryService.Received().QueryAsync( + Arg.Is(f => f.ExecutionId == executionId), + Arg.Any(), + Arg.Any()); + }); + } + + [Fact] + public void NavigateWithUnparseableExecutionIdParam_IsSilentlyDropped_NoAutoLoad() + { + _queryService = Substitute.For(); + + var cut = RenderAuditLogPageWithQuery("executionId=not-a-guid", "Admin"); + + // An unparseable executionId leaves ExecutionId null. With no other filter + // params present the page renders but does NOT call the query service. + cut.WaitForAssertion(() => Assert.Contains("Audit Log", cut.Markup)); + _queryService.DidNotReceive().QueryAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + [Fact] public void NavigateWithTargetParam_AppliesTargetFilter() { From 24cdfe373c9b747bcbb836aa91ec3906f28e7cc0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 16:00:09 -0400 Subject: [PATCH 13/15] feat(audit): ExecutionId filter in the CLI and ManagementService --- src/ScadaLink.CLI/Commands/AuditCommands.cs | 3 ++ .../Commands/AuditQueryHelpers.cs | 2 ++ .../Audit/AuditExportEndpoints.cs | 8 +++++ .../AuditEndpoints.cs | 8 +++++ .../Commands/AuditQueryCommandTests.cs | 27 ++++++++++++++++ .../Audit/AuditExportEndpointsTests.cs | 23 +++++++++++++ .../AuditEndpointsTests.cs | 32 +++++++++++++++++++ 7 files changed, 103 insertions(+) diff --git a/src/ScadaLink.CLI/Commands/AuditCommands.cs b/src/ScadaLink.CLI/Commands/AuditCommands.cs index 7120ffc..42d4e3a 100644 --- a/src/ScadaLink.CLI/Commands/AuditCommands.cs +++ b/src/ScadaLink.CLI/Commands/AuditCommands.cs @@ -59,6 +59,7 @@ public static class AuditCommands var targetOption = new Option("--target") { Description = "Filter by target (external system, DB connection, notification list)" }; var actorOption = new Option("--actor") { Description = "Filter by actor" }; var correlationIdOption = new Option("--correlation-id") { Description = "Filter by correlation ID" }; + var executionIdOption = new Option("--execution-id") { Description = "Filter by execution ID" }; var errorsOnlyOption = new Option("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" }; var pageSizeOption = new Option("--page-size") { Description = "Events per page (1-1000)" }; pageSizeOption.DefaultValueFactory = _ => 100; @@ -74,6 +75,7 @@ public static class AuditCommands cmd.Add(targetOption); cmd.Add(actorOption); cmd.Add(correlationIdOption); + cmd.Add(executionIdOption); cmd.Add(errorsOnlyOption); cmd.Add(pageSizeOption); cmd.Add(allOption); @@ -101,6 +103,7 @@ public static class AuditCommands Target = result.GetValue(targetOption), Actor = result.GetValue(actorOption), CorrelationId = result.GetValue(correlationIdOption), + ExecutionId = result.GetValue(executionIdOption), ErrorsOnly = result.GetValue(errorsOnlyOption), PageSize = result.GetValue(pageSizeOption), }; diff --git a/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs b/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs index 39918f5..fda804e 100644 --- a/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs +++ b/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs @@ -24,6 +24,7 @@ public sealed class AuditQueryArgs public string? Target { get; set; } public string? Actor { get; set; } public string? CorrelationId { get; set; } + public string? ExecutionId { get; set; } public bool ErrorsOnly { get; set; } public int PageSize { get; set; } = 100; } @@ -125,6 +126,7 @@ public static class AuditQueryHelpers Add("target", args.Target); Add("actor", args.Actor); Add("correlationId", args.CorrelationId); + Add("executionId", args.ExecutionId); Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture)); if (afterOccurredAtUtc.HasValue) diff --git a/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs b/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs index c369195..70b2bc3 100644 --- a/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs +++ b/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs @@ -105,6 +105,13 @@ public static class AuditExportEndpoints correlationId = parsedCorr; } + Guid? executionId = null; + if (query.TryGetValue("executionId", out var execValues) + && Guid.TryParse(execValues.ToString(), out var parsedExec)) + { + executionId = parsedExec; + } + DateTime? fromUtc = ParseUtcDate(query, "from"); DateTime? toUtc = ParseUtcDate(query, "to"); @@ -116,6 +123,7 @@ public static class AuditExportEndpoints Target: target, Actor: actor, CorrelationId: correlationId, + ExecutionId: executionId, FromUtc: fromUtc, ToUtc: toUtc); } diff --git a/src/ScadaLink.ManagementService/AuditEndpoints.cs b/src/ScadaLink.ManagementService/AuditEndpoints.cs index 49b50f3..a1d2572 100644 --- a/src/ScadaLink.ManagementService/AuditEndpoints.cs +++ b/src/ScadaLink.ManagementService/AuditEndpoints.cs @@ -395,6 +395,13 @@ public static class AuditEndpoints correlationId = parsedCorr; } + Guid? executionId = null; + if (query.TryGetValue("executionId", out var execValues) + && Guid.TryParse(execValues.ToString(), out var parsedExec)) + { + executionId = parsedExec; + } + return new AuditLogQueryFilter( Channels: channels, Kinds: kinds, @@ -403,6 +410,7 @@ public static class AuditEndpoints Target: TrimToNullable(query, "target"), Actor: TrimToNullable(query, "actor"), CorrelationId: correlationId, + ExecutionId: executionId, FromUtc: ParseUtcDate(query, "fromUtc"), ToUtc: ParseUtcDate(query, "toUtc")); } diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs index 3df692b..62c5abb 100644 --- a/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs @@ -65,6 +65,7 @@ public class AuditQueryCommandTests Target = "weather-api", Actor = "multi-role", CorrelationId = "abc-123", + ExecutionId = "def-456", ErrorsOnly = false, PageSize = 250, }; @@ -81,6 +82,7 @@ public class AuditQueryCommandTests Assert.Equal("weather-api", parsed["target"]); Assert.Equal("multi-role", parsed["actor"]); Assert.Equal("abc-123", parsed["correlationId"]); + Assert.Equal("def-456", parsed["executionId"]); Assert.Equal("250", parsed["pageSize"]); Assert.Equal("2026-05-20T11:00:00.0000000+00:00", parsed["fromUtc"]); Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]); @@ -155,9 +157,22 @@ public class AuditQueryCommandTests Assert.Null(parsed["channel"]); Assert.Null(parsed["status"]); Assert.Null(parsed["fromUtc"]); + Assert.Null(parsed["correlationId"]); + Assert.Null(parsed["executionId"]); Assert.Equal("100", parsed["pageSize"]); } + [Fact] + public void BuildQueryString_ExecutionId_EmitsExecutionIdParameter() + { + // --execution-id is a single-value Guid filter — mirrors --correlation-id. + var now = DateTimeOffset.UtcNow; + var args = new AuditQueryArgs { ExecutionId = "11111111-1111-1111-1111-111111111111" }; + var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null); + var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); + Assert.Equal("11111111-1111-1111-1111-111111111111", parsed["executionId"]); + } + // ---- HTTP execution / paging ------------------------------------------ private sealed class RecordingHandler : HttpMessageHandler @@ -281,6 +296,18 @@ public class AuditQueryCommandTests Assert.Empty(parse.Errors); } + [Fact] + public void Query_ExecutionIdOption_IsAccepted() + { + // --execution-id is a single-value option — mirrors --correlation-id. + var root = AuditCommandTestHarness.BuildRoot(); + var parse = root.Parse(new[] + { + "audit", "query", "--execution-id", "11111111-1111-1111-1111-111111111111", + }); + Assert.Empty(parse.Errors); + } + // ---- Enum-name validation (fast-fail) ---------------------------------- [Fact] diff --git a/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs b/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs index b91cc07..dcccf89 100644 --- a/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs @@ -138,6 +138,7 @@ public class AuditExportEndpointsTests using (host) { var correlationId = Guid.NewGuid().ToString(); + var executionId = Guid.NewGuid().ToString(); var url = "/api/centralui/audit/export?" + "channel=ApiOutbound&" + @@ -147,6 +148,7 @@ public class AuditExportEndpointsTests "target=PaymentApi&" + "actor=apikey-1&" + $"correlationId={correlationId}&" + + $"executionId={executionId}&" + "from=2026-05-20T00:00:00Z&" + "to=2026-05-20T23:59:59Z"; @@ -167,6 +169,7 @@ public class AuditExportEndpointsTests f.Target == "PaymentApi" && f.Actor == "apikey-1" && f.CorrelationId == Guid.Parse(correlationId) && + f.ExecutionId == Guid.Parse(executionId) && f.FromUtc == new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc) && f.ToUtc == new DateTime(2026, 5, 20, 23, 59, 59, DateTimeKind.Utc)), Arg.Any(), @@ -195,6 +198,7 @@ public class AuditExportEndpointsTests f.Target == null && f.Actor == null && f.CorrelationId == null && + f.ExecutionId == null && f.FromUtc == null && f.ToUtc == null), Arg.Any(), @@ -222,6 +226,25 @@ public class AuditExportEndpointsTests } } + [Fact] + public async Task ExportEndpoint_UnparseableExecutionId_SilentlyDropped() + { + // Lax-parse contract: an unparseable executionId is dropped (no 400) — + // mirrors the correlationId parse. + var (client, repo, host) = await BuildHostAsync(); + using (host) + { + var response = await client.GetAsync("/api/centralui/audit/export?executionId=not-a-guid"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + _ = await response.Content.ReadAsStringAsync(); + + await repo.Received().QueryAsync( + Arg.Is(f => f.ExecutionId == null), + Arg.Any(), + Arg.Any()); + } + } + /// /// Test-only authentication handler that signs every request in as an Admin. /// Admin is in AuditExportRoles, so the endpoint's AuditExport policy diff --git a/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs b/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs index 80fb691..7a2813a 100644 --- a/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs +++ b/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs @@ -428,6 +428,38 @@ public class AuditEndpointsTests Assert.Null(filter.Statuses); } + [Fact] + public void ParseFilter_ExecutionId_ParsesIntoSingleValueGuid() + { + // executionId is a single-value Guid? filter — mirrors correlationId. + var executionId = Guid.NewGuid(); + var query = new Microsoft.AspNetCore.Http.QueryCollection( + new Dictionary + { + ["executionId"] = executionId.ToString(), + }); + + var filter = AuditEndpoints.ParseFilter(query); + + Assert.Equal(executionId, filter.ExecutionId); + } + + [Fact] + public void ParseFilter_UnparseableExecutionId_IsDroppedSilently() + { + // Lax-parse contract: an unparseable executionId is dropped (no 400) — + // mirrors the correlationId parse. + var query = new Microsoft.AspNetCore.Http.QueryCollection( + new Dictionary + { + ["executionId"] = "not-a-guid", + }); + + var filter = AuditEndpoints.ParseFilter(query); + + Assert.Null(filter.ExecutionId); + } + [Fact] public async Task Query_RepeatedChannelParams_ReachRepositoryAsMultiValueFilter() { From fd76c19007eb13bdd937d4d49f42e35587e7fcb1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 16:06:40 -0400 Subject: [PATCH 14/15] test(auditlog): end-to-end ExecutionId correlation + docs --- CLAUDE.md | 1 + docs/requirements/Component-AuditLog.md | 23 ++ .../ExecutionIdCorrelationTests.cs | 274 ++++++++++++++++++ 3 files changed, 298 insertions(+) create mode 100644 tests/ScadaLink.AuditLog.Tests/Integration/ExecutionIdCorrelationTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 6e22243..f60e607 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,6 +132,7 @@ This project contains design documentation for a distributed SCADA system built - Layered design — append-only `AuditLog` (#23) sits alongside operational `Notifications` (#21) and `SiteCalls` (#22), not replacing them. - Scope = script trust boundary: outbound API (sync + cached), outbound DB (sync + cached), notifications, inbound API. Framework/internal traffic is explicitly excluded. - One row per lifecycle event; cached calls produce 4+ rows per operation (`Submitted`, `Forwarded`, `Attempted`, `Delivered`/`Parked`/`Discarded`). +- `ExecutionId` (`uniqueidentifier NULL`) is the universal per-run correlation value — every audit row emitted by one script execution / inbound request shares it; `CorrelationId` remains the per-operation lifecycle id (NULL for sync one-shots). - Site SQLite hot-path first, then gRPC telemetry to central; ingest is idempotent on `EventId`; periodic reconciliation pull as fallback when telemetry is lost. - Cached operations: site emits a single additively-extended `CachedCallTelemetry` packet carrying both audit events and operational state; central writes `AuditLog` + `SiteCalls` in one transaction. - Payload cap 8 KB by default / 64 KB on error rows; auth headers redacted by default; SQL parameter values captured by default; per-target redaction opt-in. diff --git a/docs/requirements/Component-AuditLog.md b/docs/requirements/Component-AuditLog.md index e3f6ed5..b8627d8 100644 --- a/docs/requirements/Component-AuditLog.md +++ b/docs/requirements/Component-AuditLog.md @@ -83,6 +83,7 @@ row per lifecycle event across all channels. | `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound`. | | `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). | | `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. | +| `ExecutionId` | `uniqueidentifier` NULL | The originating script execution / inbound request — the universal per-run correlation value; distinct from `CorrelationId`, which is the per-operation lifecycle id. Stamped on *every* audit row emitted by one execution. | | `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events. | | `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). | | `SourceScript` | `varchar(128)` NULL | Script name within the instance. | @@ -103,6 +104,7 @@ row per lifecycle event across all channels. - `IX_AuditLog_OccurredAtUtc` — primary time-range index for global scans. - `IX_AuditLog_Site_Occurred (SourceSiteId, OccurredAtUtc)` — per-site filters. - `IX_AuditLog_Correlation (CorrelationId)` — drilldown from a single operation. +- `IX_AuditLog_Execution (ExecutionId)` — drilldown to every action of one script execution / inbound request. - `IX_AuditLog_Channel_Status_Occurred (Channel, Status, OccurredAtUtc)` — KPI / dashboard tiles. - `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X". - Monthly partitioning on `OccurredAtUtc` from day one; purge is a partition switch (see Retention & Purge). @@ -126,6 +128,27 @@ Inbound API is intentionally collapsed to a single `InboundRequest` (or `InboundAuthFailure` for auth rejections) row per request rather than a multi-event lifecycle. +### `ExecutionId` vs `CorrelationId` + +The table carries two correlation columns at different granularities: + +- **`ExecutionId`** is the *universal per-run* value: one id per script + execution (tag-change / timer-triggered or otherwise) or per inbound API + request. It is stamped on **every** audit row that run produces — the sync + `ApiCall` and `DbWrite` rows, the full cached-call lifecycle, the + `NotifySend` / `NotifyDeliver` rows, and the inbound row alike. A run that + performs no trust-boundary action emits no rows, but any run that emits + multiple rows ties them all together under one `ExecutionId`. This lets an + audit reader pull the complete trust-boundary footprint of a single script + run with one `ExecutionId` filter. +- **`CorrelationId`** is the *per-operation lifecycle* id — it groups the + multiple events of one long-running operation (`TrackedOperationId` for a + cached call, `NotificationId` for a notification, request-id for inbound + API) and is NULL for sync one-shot calls that have no operation lifecycle. + +The two are orthogonal: one execution may touch several operations (each with +its own `CorrelationId`) yet every resulting row shares the one `ExecutionId`. + ## The Site-Local `AuditLog` (SQLite) A SQLite database file on each site node, alongside the Store-and-Forward diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/ExecutionIdCorrelationTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/ExecutionIdCorrelationTests.cs new file mode 100644 index 0000000..6ca8b77 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Integration/ExecutionIdCorrelationTests.cs @@ -0,0 +1,274 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using ScadaLink.AuditLog.Central; +using ScadaLink.AuditLog.Site; +using ScadaLink.AuditLog.Site.Telemetry; +using ScadaLink.AuditLog.Tests.Integration.Infrastructure; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.ConfigurationDatabase; +using ScadaLink.ConfigurationDatabase.Repositories; +using ScadaLink.ConfigurationDatabase.Tests.Migrations; +using ScadaLink.SiteRuntime.Scripts; + +namespace ScadaLink.AuditLog.Tests.Integration; + +/// +/// Audit Log #23 — ExecutionId end-to-end correlation suite verifying the +/// universal per-run correlation promise: every audit row produced by one +/// script execution carries the same non-null . +/// +/// +/// +/// This is the integration-level counterpart to the unit-level +/// ExecutionCorrelationContextTests in ScadaLink.SiteRuntime.Tests: +/// where that test asserts the shared id on the in-memory captured rows, this +/// suite drives the rows all the way through the production pipeline — the real +/// site hot-path, the real +/// drain loop, the real +/// , and the real +/// over the per-class MSSQL database — then +/// reads the rows back from the central store and asserts the shared id. +/// +/// +/// Composes the same pipeline as the M2 +/// and the M4 : an in-memory +/// + + +/// on the site, drained by a real +/// through the shared +/// stub that short-circuits the +/// gRPC wire and Asks the central ingest actor. The production +/// is driven directly: one context performs +/// two distinct trust-boundary actions — a sync ExternalSystem.Call and a +/// sync Database write — so the two emitted audit rows originate from one +/// execution. Each test uses a unique ExecutionId + SourceSiteId +/// (Guid suffixes) so concurrent tests sharing the MSSQL fixture don't interfere. +/// +/// +public class ExecutionIdCorrelationTests : TestKit, IClassFixture +{ + private readonly MsSqlMigrationFixture _fixture; + + public ExecutionIdCorrelationTests(MsSqlMigrationFixture fixture) + { + _fixture = fixture; + } + + private const string ConnectionName = "machineData"; + private const string InstanceName = "Plant.Pump42"; + private const string SourceScript = "ScriptActor:OnTick"; + + private static string NewSiteId() => + "test-execid-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + private ScadaLinkDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(_fixture.ConnectionString) + .Options; + return new ScadaLinkDbContext(options); + } + + /// + /// Per-test in-memory SQLite database with a tiny single-table schema the + /// sync DB write targets. The keep-alive root pins the in-memory file for + /// the duration of the test; the returned live connection is what the + /// stub gateway hands back to the auditing wrapper. Mirrors + /// DatabaseSyncEmissionEndToEndTests.NewInMemoryDb. + /// + private static SqliteConnection NewInMemoryDb(out SqliteConnection keepAlive) + { + var dbName = $"db-{Guid.NewGuid():N}"; + var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; + + keepAlive = new SqliteConnection(connStr); + keepAlive.Open(); + using (var seed = keepAlive.CreateCommand()) + { + seed.CommandText = + "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);"; + seed.ExecuteNonQuery(); + } + + var live = new SqliteConnection(connStr); + live.Open(); + return live; + } + + private static SqliteAuditWriter CreateInMemorySqliteWriter() => + new( + Options.Create(new SqliteAuditWriterOptions + { + DatabasePath = "ignored", + BatchSize = 64, + ChannelCapacity = 1024, + }), + NullLogger.Instance, + connectionStringOverride: + $"Data Source=file:auditlog-execid-{Guid.NewGuid():N}?mode=memory&cache=shared"); + + private static IOptions FastTelemetryOptions() => + Options.Create(new SiteAuditTelemetryOptions + { + BatchSize = 256, + // 1s on both intervals so the initial scheduled tick fires quickly + // — drains the SQLite Pending rows and pushes them through the stub + // gRPC client into the central ingest actor. + BusyIntervalSeconds = 1, + IdleIntervalSeconds = 1, + }); + + private IActorRef CreateIngestActor(IAuditLogRepository repo) => + Sys.ActorOf(Props.Create(() => new AuditLogIngestActor( + repo, + NullLogger.Instance))); + + private IActorRef CreateTelemetryActor( + ISiteAuditQueue queue, + ISiteStreamAuditClient client) => + Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor( + queue, + client, + FastTelemetryOptions(), + NullLogger.Instance))); + + [SkippableFact] + public async Task OneExecution_ApiCallAndDbWrite_AllCentralRows_ShareOneNonNullExecutionId() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + // An explicit per-run execution id — the value the test asserts on every + // audit row produced by the single script execution below. + var executionId = Guid.NewGuid(); + + // ── Central — repository + ingest actor backed by the MSSQL fixture ── + await using var ingestContext = CreateContext(); + var ingestRepo = new AuditLogRepository(ingestContext); + var ingestActor = CreateIngestActor(ingestRepo); + + // ── Site — SQLite audit writer + ring + fallback + telemetry actor ─── + 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); + + // Outbound API client — one successful CallAsync, one audit row. + var externalClient = Substitute.For(); + externalClient + .CallAsync("ERP", "GetOrder", Arg.Any?>(), Arg.Any()) + .Returns(new ExternalCallResult(true, "{}", null)); + + // SQLite-backed inner DB connection — the stub gateway hands it to the + // auditing wrapper as the connection the script would have got. + using var keepAlive = new SqliteConnection("Data Source=execid-k1;Mode=Memory;Cache=Shared"); + var innerDb = NewInMemoryDb(out _); + var gateway = Substitute.For(); + gateway.GetConnectionAsync(ConnectionName, Arg.Any()) + .Returns(innerDb); + + // ── Act — ONE script execution: a sync ExternalSystem.Call AND a sync + // Database write, both performed through a SINGLE ScriptRuntimeContext + // stamped with the explicit executionId. Each helper emits exactly one + // trust-boundary audit row to the fallback writer; the telemetry actor's + // next tick drains both to central. + var context = CreateScriptContext(externalClient, gateway, fallback, siteId, executionId); + + await context.ExternalSystem.Call("ERP", "GetOrder"); + + await using (var conn = await context.Database.Connection(ConnectionName)) + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "INSERT INTO t (id, name) VALUES (1, 'alpha')"; + var affected = await cmd.ExecuteNonQueryAsync(); + Assert.Equal(1, affected); + } + + // ── Assert — read the rows back from the CENTRAL store filtered by the + // execution id; both the ApiCall and the DbWrite row must be present and + // every one must carry the SAME non-null ExecutionId we minted above. + await AwaitAssertAsync(async () => + { + await using var readContext = CreateContext(); + var readRepo = new AuditLogRepository(readContext); + + // The ExecutionId filter dimension is the universal-correlation + // query an audit reader uses to pull every action of one run. + var rows = await readRepo.QueryAsync( + new AuditLogQueryFilter(ExecutionId: executionId), + new AuditLogPaging(PageSize: 10)); + + // Both trust-boundary actions of the one execution have landed. + Assert.Equal(2, rows.Count); + + // Every central row carries the SAME non-null ExecutionId — the + // core promise of the per-run correlation value. + Assert.All(rows, r => + { + Assert.NotNull(r.ExecutionId); + Assert.Equal(executionId, r.ExecutionId); + Assert.Equal(siteId, r.SourceSiteId); + // Central stamps IngestedAtUtc; the site never sets it. + Assert.NotNull(r.IngestedAtUtc); + }); + + // The two rows are the two distinct trust-boundary actions — one + // outbound API call and one outbound DB write — proving the shared + // id spans different channels, not two rows of the same action. + Assert.Single(rows, r => r.Channel == AuditChannel.ApiOutbound && r.Kind == AuditKind.ApiCall); + Assert.Single(rows, r => r.Channel == AuditChannel.DbOutbound && r.Kind == AuditKind.DbWrite); + }, TimeSpan.FromSeconds(15)); + } + + /// + /// Builds a production wired with the + /// outbound external-system client, the database gateway and the audit + /// writer, stamped with an explicit . The + /// actor refs are — the ExternalSystem / + /// Database helpers exercised here never touch them. + /// + private static ScriptRuntimeContext CreateScriptContext( + IExternalSystemClient externalSystemClient, + IDatabaseGateway databaseGateway, + IAuditWriter auditWriter, + string siteId, + Guid executionId) + { + var compilationService = new ScriptCompilationService( + NullLogger.Instance); + var sharedScriptLibrary = new SharedScriptLibrary( + compilationService, NullLogger.Instance); + + return new ScriptRuntimeContext( + ActorRefs.Nobody, + ActorRefs.Nobody, + sharedScriptLibrary, + currentCallDepth: 0, + maxCallDepth: 10, + askTimeout: TimeSpan.FromSeconds(5), + instanceName: InstanceName, + logger: NullLogger.Instance, + externalSystemClient: externalSystemClient, + databaseGateway: databaseGateway, + storeAndForward: null, + siteCommunicationActor: null, + siteId: siteId, + sourceScript: SourceScript, + auditWriter: auditWriter, + operationTrackingStore: null, + cachedForwarder: null, + executionId: executionId); + } +} From 5198b114b40a8cfb7f7248776c93b0fd1b2282a8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 16:18:17 -0400 Subject: [PATCH 15/15] fix(auditlog): evolve existing site auditlog.db schema for ExecutionId --- docs/requirements/Component-AuditLog.md | 2 +- .../Site/SqliteAuditWriter.cs | 40 ++++++ .../Site/SqliteAuditWriterSchemaTests.cs | 120 ++++++++++++++++++ 3 files changed, 161 insertions(+), 1 deletion(-) diff --git a/docs/requirements/Component-AuditLog.md b/docs/requirements/Component-AuditLog.md index b8627d8..7fdeb66 100644 --- a/docs/requirements/Component-AuditLog.md +++ b/docs/requirements/Component-AuditLog.md @@ -103,7 +103,7 @@ row per lifecycle event across all channels. - `IX_AuditLog_OccurredAtUtc` — primary time-range index for global scans. - `IX_AuditLog_Site_Occurred (SourceSiteId, OccurredAtUtc)` — per-site filters. -- `IX_AuditLog_Correlation (CorrelationId)` — drilldown from a single operation. +- `IX_AuditLog_CorrelationId (CorrelationId)` — drilldown from a single operation. - `IX_AuditLog_Execution (ExecutionId)` — drilldown to every action of one script execution / inbound request. - `IX_AuditLog_Channel_Status_Occurred (Channel, Status, OccurredAtUtc)` — KPI / dashboard tiles. - `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X". diff --git a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs index cf2b216..72c69d0 100644 --- a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs +++ b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs @@ -121,6 +121,46 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable ON AuditLog (ForwardState, OccurredAtUtc); """; cmd.ExecuteNonQuery(); + + // Audit Log #23 (ExecutionId): additively add the ExecutionId column. + // CREATE TABLE IF NOT EXISTS above does NOT add columns to an AuditLog + // table that already exists from a pre-ExecutionId build, so an + // auditlog.db created by an older build needs the column ALTER-ed in. + // The file is durable across restart/failover by design (7-day + // retention), so without this step every WriteAsync on an upgraded + // deployment would bind $ExecutionId against a missing column and the + // best-effort write path would silently drop every site audit row. + // SQLite has no "ADD COLUMN IF NOT EXISTS"; the column presence is + // probed first and the ALTER skipped when already there. The column is + // nullable with no default, so any row written before this migration + // reads back ExecutionId = null (back-compat). + AddColumnIfMissing("ExecutionId", "TEXT NULL"); + } + + /// + /// Audit Log #23 (ExecutionId): adds a column to AuditLog only when + /// it is not already present. SQLite lacks ADD COLUMN IF NOT EXISTS, + /// so the schema is probed via PRAGMA table_info first. Idempotent — + /// safe to run on every . Mirrors + /// StoreAndForwardStorage.AddColumnIfMissingAsync; kept synchronous + /// here to match the rest of this writer's bootstrap DDL. + /// + private void AddColumnIfMissing(string columnName, string columnDefinition) + { + using var probe = _connection.CreateCommand(); + probe.CommandText = "SELECT COUNT(*) FROM pragma_table_info('AuditLog') WHERE name = $name"; + probe.Parameters.AddWithValue("$name", columnName); + var exists = Convert.ToInt32(probe.ExecuteScalar()) > 0; + if (exists) + { + return; + } + + using var alter = _connection.CreateCommand(); + // Column name + definition are caller-controlled constants, never user + // input — safe to interpolate (parameters are not permitted in DDL). + alter.CommandText = $"ALTER TABLE AuditLog ADD COLUMN {columnName} {columnDefinition}"; + alter.ExecuteNonQuery(); } /// diff --git a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs index e6015fd..13120e3 100644 --- a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs @@ -2,6 +2,8 @@ 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; @@ -125,4 +127,122 @@ public class SqliteAuditWriterSchemaTests Assert.Equal(2, value); } } + + // ----- ExecutionId schema-upgrade regression (persistent auditlog.db) ----- // + + /// + /// The OLD pre-ExecutionId-branch AuditLog schema — the 20-column + /// CREATE TABLE WITHOUT the ExecutionId column. A real deployment's + /// on-disk auditlog.db already contains exactly this shape, and + /// CREATE TABLE IF NOT EXISTS is a no-op against it. + /// + private const string OldPreExecutionIdSchema = """ + 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); + """; + + /// + /// Seeds a shared-cache in-memory database with the OLD 20-column schema and + /// returns the open connection. The connection MUST stay open for the + /// lifetime of the test: a shared-cache in-memory database is dropped once + /// its last connection closes, so closing this would discard the seeded + /// schema before the writer opens its own connection. + /// + private static SqliteConnection SeedOldSchemaDatabase(string dataSource) + { + var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared"); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = OldPreExecutionIdSchema; + cmd.ExecuteNonQuery(); + return connection; + } + + private static SqliteAuditWriter CreateWriterOver(string dataSource) + { + var options = new SqliteAuditWriterOptions { DatabasePath = dataSource }; + return new SqliteAuditWriter( + Options.Create(options), + NullLogger.Instance, + connectionStringOverride: $"Data Source={dataSource};Cache=Shared"); + } + + private static bool ColumnExists(SqliteConnection connection, string columnName) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM pragma_table_info('AuditLog') WHERE name = $name"; + cmd.Parameters.AddWithValue("$name", columnName); + return Convert.ToInt32(cmd.ExecuteScalar()) > 0; + } + + [Fact] + public async Task Opening_Over_PreExisting_OldSchema_Db_Adds_ExecutionId_Column_And_WriteAsync_RoundTrips() + { + var dataSource = $"file:{nameof(Opening_Over_PreExisting_OldSchema_Db_Adds_ExecutionId_Column_And_WriteAsync_RoundTrips)}-{Guid.NewGuid():N}?mode=memory&cache=shared"; + + // A pre-branch deployment: auditlog.db already exists with the 20-column + // schema and NO ExecutionId column. + using var seedConnection = SeedOldSchemaDatabase(dataSource); + Assert.False(ColumnExists(seedConnection, "ExecutionId")); + + // Upgrade: a post-branch SqliteAuditWriter opens the same database. Its + // InitializeSchema must ALTER the missing ExecutionId column in — the + // CREATE TABLE IF NOT EXISTS alone is a no-op against the existing table. + var executionId = Guid.NewGuid(); + await using (var writer = CreateWriterOver(dataSource)) + { + Assert.True( + ColumnExists(seedConnection, "ExecutionId"), + "SqliteAuditWriter must ALTER the ExecutionId column into a pre-existing AuditLog table."); + + // A WriteAsync binding $ExecutionId must now succeed and round-trip; + // without the ALTER it would fail with "no such column: ExecutionId" + // and — because audit writes are best-effort — silently drop the row. + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Delivered, + PayloadTruncated = false, + ExecutionId = executionId, + }; + await writer.WriteAsync(evt); + + var rows = await writer.ReadPendingAsync(limit: 10); + var row = Assert.Single(rows); + Assert.Equal(executionId, row.ExecutionId); + } + + // Idempotency: a second writer over the now-upgraded DB must not error + // (the probe sees ExecutionId already present and skips the ALTER). + await using (var writerAgain = CreateWriterOver(dataSource)) + { + Assert.True(ColumnExists(seedConnection, "ExecutionId")); + } + } }