Compare commits
27 Commits
feature/au
...
feature/au
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5198b114b4 | ||
|
|
fd76c19007 | ||
|
|
24cdfe373c | ||
|
|
1ba62052d6 | ||
|
|
cfd8f1ecf4 | ||
|
|
6aac4c8ed7 | ||
|
|
85bb61a1f3 | ||
|
|
705ae95404 | ||
|
|
6f5a35f222 | ||
|
|
0149ce6180 | ||
|
|
6b16a48886 | ||
|
|
990731d12f | ||
|
|
fd12021984 | ||
|
|
4002f4197b | ||
|
|
6ffa47f258 | ||
|
|
c9229c35fc | ||
|
|
aadb1fd72a | ||
|
|
8243f61e96 | ||
|
|
53508c79b2 | ||
|
|
849a011400 | ||
|
|
405de525ca | ||
|
|
77922abb33 | ||
|
|
5f544bfe1e | ||
|
|
aaa6df24cf | ||
|
|
ae7329034f | ||
|
|
e36f0bf9c8 | ||
|
|
a3eb659b75 |
@@ -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.
|
||||
|
||||
115
docs/plans/2026-05-21-audit-executionid-design.md
Normal file
115
docs/plans/2026-05-21-audit-executionid-design.md
Normal file
@@ -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=<guid>`; a row drill-in
|
||||
"View this execution" → `/audit/log?executionId=<guid>`.
|
||||
- **CLI** — `scadalink audit query --execution-id <guid>`.
|
||||
- **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.
|
||||
155
docs/plans/2026-05-21-audit-executionid.md
Normal file
155
docs/plans/2026-05-21-audit-executionid.md
Normal file
@@ -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 <path>` — 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 = <request id>`).
|
||||
- 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=<guid>`; `BuildExportUrl` emits it.
|
||||
- Add a "View this execution" drill-in — a row/drilldown action linking `/audit/log?executionId=<guid>`. 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 <guid>`; `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.
|
||||
16
docs/plans/2026-05-21-audit-executionid.md.tasks.json
Normal file
16
docs/plans/2026-05-21-audit-executionid.md.tasks.json
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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. |
|
||||
@@ -102,7 +103,8 @@ 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".
|
||||
- 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
|
||||
|
||||
@@ -114,12 +114,53 @@ 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
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ExecutionId): adds a column to <c>AuditLog</c> only when
|
||||
/// it is not already present. SQLite lacks <c>ADD COLUMN IF NOT EXISTS</c>,
|
||||
/// so the schema is probed via <c>PRAGMA table_info</c> first. Idempotent —
|
||||
/// safe to run on every <see cref="InitializeSchema"/>. Mirrors
|
||||
/// <c>StoreAndForwardStorage.AddColumnIfMissingAsync</c>; kept synchronous
|
||||
/// here to match the rest of this writer's bootstrap DDL.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -221,12 +262,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 +293,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 +318,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 +376,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 +425,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 +512,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 +690,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
PayloadTruncated = reader.GetInt32(17) != 0,
|
||||
Extra = reader.IsDBNull(18) ? null : reader.GetString(18),
|
||||
ForwardState = Enum.Parse<AuditForwardState>(reader.GetString(19)),
|
||||
ExecutionId = reader.IsDBNull(20) ? null : Guid.Parse(reader.GetString(20)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -59,6 +59,7 @@ public static class AuditCommands
|
||||
var targetOption = new Option<string?>("--target") { Description = "Filter by target (external system, DB connection, notification list)" };
|
||||
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
|
||||
var correlationIdOption = new Option<string?>("--correlation-id") { Description = "Filter by correlation ID" };
|
||||
var executionIdOption = new Option<string?>("--execution-id") { Description = "Filter by execution ID" };
|
||||
var errorsOnlyOption = new Option<bool>("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" };
|
||||
var pageSizeOption = new Option<int>("--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),
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
<dt class="col-4 text-muted fw-normal">CorrelationId</dt>
|
||||
<dd class="col-8 font-monospace" data-test="field-CorrelationId">@(Event.CorrelationId?.ToString() ?? "—")</dd>
|
||||
|
||||
<dt class="col-4 text-muted fw-normal">ExecutionId</dt>
|
||||
<dd class="col-8 font-monospace" data-test="field-ExecutionId">@(Event.ExecutionId?.ToString() ?? "—")</dd>
|
||||
|
||||
<dt class="col-4 text-muted fw-normal">OccurredAtUtc</dt>
|
||||
<dd class="col-8 font-monospace" data-test="field-OccurredAtUtc">@FormatTimestamp(Event.OccurredAtUtc)</dd>
|
||||
|
||||
@@ -151,6 +154,14 @@
|
||||
Show all events for this operation
|
||||
</button>
|
||||
}
|
||||
@if (Event.ExecutionId is not null)
|
||||
{
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
data-test="view-this-execution"
|
||||
@onclick="ViewThisExecution">
|
||||
View this execution
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-primary btn-sm ms-auto"
|
||||
data-test="drawer-close-footer"
|
||||
@onclick="HandleClose">
|
||||
|
||||
@@ -47,9 +47,10 @@ namespace ScadaLink.CentralUI.Components.Audit;
|
||||
/// <para>
|
||||
/// <b>Drill-back.</b> When <see cref="AuditEvent.CorrelationId"/> is set,
|
||||
/// the "Show all events" button navigates to
|
||||
/// <c>/audit/log?correlationId={id}</c>. The parent page does not
|
||||
/// auto-apply that filter today — it is a deep link the page can use
|
||||
/// when Bundle D wires up query-string deserialization.
|
||||
/// <c>/audit/log?correlationId={id}</c>. Likewise, when
|
||||
/// <see cref="AuditEvent.ExecutionId"/> is set the "View this execution"
|
||||
/// button navigates to <c>/audit/log?executionId={id}</c>. Both are deep
|
||||
/// links the Audit Log page deserializes on init (Bundle D) and auto-loads.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public partial class AuditDrilldownDrawer
|
||||
@@ -276,6 +277,20 @@ public partial class AuditDrilldownDrawer
|
||||
Navigation.NavigateTo(uri);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drill-in to every audit row sharing this row's <see cref="AuditEvent.ExecutionId"/>
|
||||
/// — the universal per-run correlation value, distinct from the per-operation
|
||||
/// CorrelationId drill-back above. Navigates to <c>/audit/log?executionId={id}</c>,
|
||||
/// which the page parses on init and auto-loads. The button is only rendered
|
||||
/// when <see cref="AuditEvent.ExecutionId"/> is non-null, so this is total.
|
||||
/// </summary>
|
||||
private void ViewThisExecution()
|
||||
{
|
||||
if (Event?.ExecutionId is not { } exec) return;
|
||||
var uri = $"/audit/log?executionId={exec}";
|
||||
Navigation.NavigateTo(uri);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a cURL command from an audit event. The URL comes from
|
||||
/// <c>Target</c>; when the RequestSummary parses as
|
||||
|
||||
@@ -6,78 +6,58 @@
|
||||
|
||||
<div class="card mb-3" data-test="audit-filter-bar">
|
||||
<div class="card-body py-2">
|
||||
@* Channel chip multi-select. *@
|
||||
<div class="mb-2" data-test="filter-channel">
|
||||
<label class="form-label small mb-1">Channel</label>
|
||||
<div>
|
||||
@foreach (var channel in Enum.GetValues<AuditChannel>())
|
||||
{
|
||||
var selected = _model.Channels.Contains(channel);
|
||||
<button type="button" data-test="chip-channel-@channel"
|
||||
class="@ChipClass(selected)"
|
||||
@onclick="() => ToggleChannel(channel)">
|
||||
@channel
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Kind chip multi-select — narrowed by Channel selection. *@
|
||||
<div class="mb-2" data-test="filter-kind">
|
||||
<label class="form-label small mb-1">Kind</label>
|
||||
<div>
|
||||
@foreach (var kind in _model.VisibleKinds())
|
||||
{
|
||||
var selected = _model.Kinds.Contains(kind);
|
||||
<button type="button" data-test="chip-kind-@kind"
|
||||
class="@ChipClass(selected)"
|
||||
@onclick="() => ToggleKind(kind)">
|
||||
@kind
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Status chip multi-select. *@
|
||||
<div class="mb-2" data-test="filter-status">
|
||||
<label class="form-label small mb-1">Status</label>
|
||||
<div>
|
||||
@foreach (var status in Enum.GetValues<AuditStatus>())
|
||||
{
|
||||
var selected = _model.Statuses.Contains(status);
|
||||
<button type="button" data-test="chip-status-@status"
|
||||
class="@ChipClass(selected)"
|
||||
@onclick="() => ToggleStatus(status)">
|
||||
@status
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Site chip multi-select — populated from ISiteRepository. *@
|
||||
<div class="mb-2" data-test="filter-site">
|
||||
<label class="form-label small mb-1">Site</label>
|
||||
<div>
|
||||
@if (_sites.Count == 0)
|
||||
{
|
||||
<span class="text-muted small">No sites available.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var site in _sites)
|
||||
{
|
||||
var selected = _model.SiteIdentifiers.Contains(site.SiteIdentifier);
|
||||
<button type="button" data-test="chip-site-@site.SiteIdentifier"
|
||||
class="@ChipClass(selected)"
|
||||
@onclick="() => ToggleSite(site.SiteIdentifier)">
|
||||
@site.Name
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* All filters sit in one wrapped row. Kind / Status / Site use compact
|
||||
MultiSelectDropdown controls; Channel is a single-select because the
|
||||
Kind options narrow to the chosen channel — so the bar stays a row or
|
||||
two tall instead of four stacked blocks of chip buttons. *@
|
||||
<div class="row g-2 align-items-end">
|
||||
@* Single-select: one channel at a time, so the Kind options below
|
||||
narrow cleanly to that channel. "All channels" clears it. *@
|
||||
<div class="col-auto" data-test="filter-channel">
|
||||
<label class="form-label small mb-1" for="audit-channel">Channel</label>
|
||||
<select id="audit-channel" data-test="filter-channel-select"
|
||||
class="form-select form-select-sm" @bind="SelectedChannel">
|
||||
<option value="">All channels</option>
|
||||
@foreach (var channel in _channels)
|
||||
{
|
||||
<option value="@channel">@channel</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@* Kind options are narrowed by the Channel selection (VisibleKinds). *@
|
||||
<div class="col-auto" data-test="filter-kind">
|
||||
<label class="form-label small mb-1">Kind</label>
|
||||
<div>
|
||||
<MultiSelectDropdown TValue="AuditKind"
|
||||
Items="_model.VisibleKinds()"
|
||||
Selected="_model.Kinds"
|
||||
DataTest="filter-kind-ms" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto" data-test="filter-status">
|
||||
<label class="form-label small mb-1">Status</label>
|
||||
<div>
|
||||
<MultiSelectDropdown TValue="AuditStatus"
|
||||
Items="_statuses"
|
||||
Selected="_model.Statuses"
|
||||
DataTest="filter-status-ms" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto" data-test="filter-site">
|
||||
<label class="form-label small mb-1">Site</label>
|
||||
<div>
|
||||
<MultiSelectDropdown TValue="string"
|
||||
Items="_siteIds"
|
||||
Selected="_model.SiteIdentifiers"
|
||||
Display="SiteName"
|
||||
EmptyText="No sites available"
|
||||
DataTest="filter-site-ms" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto" data-test="filter-time-range">
|
||||
<label class="form-label small mb-1" for="audit-time-range">Time range</label>
|
||||
<select id="audit-time-range" class="form-select form-select-sm"
|
||||
@@ -137,6 +117,16 @@
|
||||
placeholder="contains…" @bind="_model.ActorSearch" />
|
||||
</div>
|
||||
|
||||
@* 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. *@
|
||||
<div class="col-auto" data-test="filter-execution-id">
|
||||
<label class="form-label small mb-1" for="audit-execution-id">Execution ID</label>
|
||||
<input id="audit-execution-id" type="text"
|
||||
class="form-control form-control-sm font-monospace"
|
||||
placeholder="paste GUID…" @bind="_model.ExecutionId" />
|
||||
</div>
|
||||
|
||||
<div class="col-auto" data-test="filter-errors-only">
|
||||
<div class="form-check mb-1">
|
||||
<input class="form-check-input" type="checkbox" id="audit-errors-only"
|
||||
|
||||
@@ -7,19 +7,32 @@ namespace ScadaLink.CentralUI.Components.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Filter bar for the central Audit Log page (#23 M7-T2). Owns the
|
||||
/// <see cref="AuditQueryModel"/> binding state, renders the 10 filter elements
|
||||
/// plus the Errors-only toggle, and publishes a collapsed
|
||||
/// <see cref="AuditLogQueryFilter"/> via <see cref="OnFilterChanged"/> when the
|
||||
/// user clicks Apply. See <see cref="AuditQueryModel"/> for the multi-select →
|
||||
/// single-value collapse contract.
|
||||
/// <see cref="AuditQueryModel"/> binding state and renders the filter controls
|
||||
/// — Channel as a single-select (one channel at a time, so the Kind options
|
||||
/// narrow to it cleanly); Kind / Status / Site as compact
|
||||
/// <see cref="ScadaLink.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
|
||||
/// controls; plus the time range, free-text searches and the Errors-only
|
||||
/// toggle — and publishes an <see cref="AuditLogQueryFilter"/> via
|
||||
/// <see cref="OnFilterChanged"/> when the user clicks Apply. The selected
|
||||
/// dimensions map through to the filter's list fields; see
|
||||
/// <see cref="AuditQueryModel"/> for the Errors-only and time-range rules.
|
||||
/// </summary>
|
||||
public partial class AuditFilterBar
|
||||
{
|
||||
private readonly AuditQueryModel _model = new();
|
||||
private List<Site> _sites = new();
|
||||
|
||||
/// <summary>Channel options — the full enum, fixed for the component's lifetime.</summary>
|
||||
private static readonly IReadOnlyList<AuditChannel> _channels = Enum.GetValues<AuditChannel>();
|
||||
|
||||
/// <summary>Status options — the full enum, fixed for the component's lifetime.</summary>
|
||||
private static readonly IReadOnlyList<AuditStatus> _statuses = Enum.GetValues<AuditStatus>();
|
||||
|
||||
/// <summary>Site identifiers in display order; rebuilt once when sites load.</summary>
|
||||
private IReadOnlyList<string> _siteIds = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the user clicks Apply. Carries the collapsed
|
||||
/// Raised when the user clicks Apply. Carries the
|
||||
/// <see cref="AuditLogQueryFilter"/> the parent page hands to
|
||||
/// <see cref="ScadaLink.CentralUI.Services.IAuditLogQueryService"/>.
|
||||
/// </summary>
|
||||
@@ -51,10 +64,9 @@ public partial class AuditFilterBar
|
||||
_model.InstanceSearch = InitialInstanceSearch.Trim();
|
||||
}
|
||||
|
||||
|
||||
// Populate the Site chips at component init. Failure is non-fatal — the chip
|
||||
// section just shows "No sites available." Sites are listed by Name to match
|
||||
// operator expectations from the Notification Report.
|
||||
// Populate the Site dropdown at component init. Failure is non-fatal — the
|
||||
// dropdown just shows "No sites available." Sites are listed by Name to
|
||||
// match operator expectations from the Notification Report.
|
||||
try
|
||||
{
|
||||
var sites = await SiteRepository.GetAllSitesAsync();
|
||||
@@ -62,48 +74,52 @@ public partial class AuditFilterBar
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallowed: filter bar still renders without the Site chips. The page
|
||||
// Swallowed: filter bar still renders without the Site options. The page
|
||||
// surfaces site-load errors elsewhere (the grid query path).
|
||||
_sites = new();
|
||||
}
|
||||
|
||||
_siteIds = _sites.Select(s => s.SiteIdentifier).ToArray();
|
||||
}
|
||||
|
||||
private void ToggleChannel(AuditChannel channel)
|
||||
/// <summary>
|
||||
/// Single-select Channel binding for the filter bar. The Audit Log filters one
|
||||
/// channel at a time so the Kind options narrow cleanly to it; the model still
|
||||
/// stores the selection as a set (0 or 1 entry) so <see cref="AuditQueryModel.ToFilter"/>
|
||||
/// and <see cref="AuditQueryModel.VisibleKinds"/> are unchanged. <c>null</c> = all channels.
|
||||
/// </summary>
|
||||
private AuditChannel? SelectedChannel
|
||||
{
|
||||
if (!_model.Channels.Add(channel))
|
||||
get => _model.Channels.Count > 0 ? _model.Channels.First() : null;
|
||||
set
|
||||
{
|
||||
_model.Channels.Remove(channel);
|
||||
}
|
||||
_model.Channels.Clear();
|
||||
if (value is { } channel)
|
||||
{
|
||||
_model.Channels.Add(channel);
|
||||
}
|
||||
|
||||
// Drop Kind chips that fall outside the new visible set. Keeps "Channel and
|
||||
// Kind both picked" coherent — without this, removing a channel could leave
|
||||
// stale Kind chips selected that no longer match any visible chip.
|
||||
OnChannelsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs after the Channel selection changes. Drops any Kind selections that fell
|
||||
/// outside the new visible set — without this, changing the channel could leave
|
||||
/// stale Kind selections that no longer match any visible option.
|
||||
/// </summary>
|
||||
private void OnChannelsChanged()
|
||||
{
|
||||
var visible = _model.VisibleKinds().ToHashSet();
|
||||
_model.Kinds.RemoveWhere(k => !visible.Contains(k));
|
||||
}
|
||||
|
||||
private void ToggleKind(AuditKind kind)
|
||||
/// <summary>Display label for a site identifier — its friendly Name, id as fallback.</summary>
|
||||
private string SiteName(string siteIdentifier)
|
||||
{
|
||||
if (!_model.Kinds.Add(kind))
|
||||
{
|
||||
_model.Kinds.Remove(kind);
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleStatus(AuditStatus status)
|
||||
{
|
||||
if (!_model.Statuses.Add(status))
|
||||
{
|
||||
_model.Statuses.Remove(status);
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleSite(string siteIdentifier)
|
||||
{
|
||||
if (!_model.SiteIdentifiers.Add(siteIdentifier))
|
||||
{
|
||||
_model.SiteIdentifiers.Remove(siteIdentifier);
|
||||
}
|
||||
var site = _sites.FirstOrDefault(s =>
|
||||
string.Equals(s.SiteIdentifier, siteIdentifier, StringComparison.OrdinalIgnoreCase));
|
||||
return site?.Name ?? siteIdentifier;
|
||||
}
|
||||
|
||||
private void ClearFilters()
|
||||
@@ -119,6 +135,7 @@ public partial class AuditFilterBar
|
||||
_model.ScriptSearch = string.Empty;
|
||||
_model.TargetSearch = string.Empty;
|
||||
_model.ActorSearch = string.Empty;
|
||||
_model.ExecutionId = string.Empty;
|
||||
_model.ErrorsOnly = false;
|
||||
}
|
||||
|
||||
@@ -129,11 +146,6 @@ public partial class AuditFilterBar
|
||||
await OnFilterChanged.InvokeAsync(filter);
|
||||
}
|
||||
|
||||
private static string ChipClass(bool selected) =>
|
||||
selected
|
||||
? "btn btn-sm btn-primary me-1 mb-1"
|
||||
: "btn btn-sm btn-outline-secondary me-1 mb-1";
|
||||
|
||||
private static string TimeRangeLabel(AuditTimeRangePreset preset) => preset switch
|
||||
{
|
||||
AuditTimeRangePreset.Last5Minutes => "now − 5 min → now",
|
||||
|
||||
@@ -47,6 +47,14 @@ public sealed class AuditQueryModel
|
||||
public string TargetSearch { get; set; } = string.Empty;
|
||||
public string ActorSearch { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Paste-in ExecutionId filter — the operator pastes the universal per-run
|
||||
/// correlation Guid. Stored as free text; <see cref="ToFilter"/> lax-parses it
|
||||
/// through <see cref="Guid.TryParse(string?, out Guid)"/> so a blank or
|
||||
/// unparseable value simply yields no constraint.
|
||||
/// </summary>
|
||||
public string ExecutionId { get; set; } = string.Empty;
|
||||
|
||||
public bool ErrorsOnly { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -83,6 +83,15 @@
|
||||
</div>
|
||||
|
||||
@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":
|
||||
<span class="small">@(row.Actor ?? "—")</span>
|
||||
break;
|
||||
case "ExecutionId":
|
||||
@if (row.ExecutionId is { } executionId)
|
||||
{
|
||||
<span class="small font-monospace"
|
||||
data-test="execution-id-@row.EventId"
|
||||
title="@executionId">@ShortGuid(executionId)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="small text-muted">—</span>
|
||||
}
|
||||
break;
|
||||
case "DurationMs":
|
||||
<span class="small font-monospace">@(row.DurationMs?.ToString() ?? "—")</span>
|
||||
break;
|
||||
|
||||
@@ -9,9 +9,10 @@ namespace ScadaLink.CentralUI.Components.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Services.IAuditLogQueryService"/>
|
||||
/// 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
|
||||
/// <see cref="Services.IAuditLogQueryService"/>
|
||||
/// — never to <c>IAuditLogRepository</c> 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"),
|
||||
|
||||
@@ -22,7 +22,8 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit;
|
||||
/// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, <c>?kind=</c>, and the UI-only
|
||||
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends
|
||||
/// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
|
||||
/// drill in to <c>?status=Failed</c>. When any param is present we allocate a
|
||||
/// drill in to <c>?status=Failed</c>. The ExecutionId follow-up adds
|
||||
/// <c>?executionId=</c> for the "View this execution" drill-in. When any param is present we allocate a
|
||||
/// fresh <see cref="AuditLogQueryFilter"/> and assign it to
|
||||
/// <see cref="_currentFilter"/>, 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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)));
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
@typeparam TValue
|
||||
@*
|
||||
Compact multi-select control: a Bootstrap dropdown whose toggle button
|
||||
summarises the current selection over a checkbox menu. Replaces a wrapped
|
||||
block of chip buttons with a single control of one row's height.
|
||||
*@
|
||||
<div class="dropdown msd" data-test="@DataTest">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary dropdown-toggle msd-toggle text-start"
|
||||
data-bs-toggle="dropdown"
|
||||
data-bs-auto-close="outside"
|
||||
aria-expanded="false"
|
||||
disabled="@(Items.Count == 0)"
|
||||
data-test="@($"{DataTest}-toggle")">
|
||||
<span class="msd-summary">@Summary()</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu msd-menu">
|
||||
@if (Items.Count == 0)
|
||||
{
|
||||
<li><span class="dropdown-item-text text-muted small">@EmptyText</span></li>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var item in Items)
|
||||
{
|
||||
var isSelected = Selected.Contains(item);
|
||||
<li>
|
||||
<label class="dropdown-item msd-item">
|
||||
<input type="checkbox"
|
||||
class="form-check-input msd-check"
|
||||
checked="@isSelected"
|
||||
@onchange="() => Toggle(item)"
|
||||
data-test="@($"{DataTest}-opt-{item}")" />
|
||||
<span>@Display(item)</span>
|
||||
</label>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,95 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// A compact multi-select control: a Bootstrap dropdown whose toggle button
|
||||
/// summarises the current selection ("All" when empty, the single item's label
|
||||
/// when one is picked, or "N selected" otherwise) over a checkbox menu.
|
||||
///
|
||||
/// <para>
|
||||
/// It exists to keep multi-value filter controls one row tall instead of a
|
||||
/// wrapped block of chip buttons. The component mutates the caller-owned
|
||||
/// <see cref="Selected"/> collection in place and raises
|
||||
/// <see cref="SelectionChanged"/> after every toggle so the parent can react
|
||||
/// (re-render, prune dependent selections, …).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Requires the Bootstrap JS bundle (loaded in <c>App.razor</c>) for the
|
||||
/// dropdown toggle; <c>data-bs-auto-close="outside"</c> keeps the menu open
|
||||
/// while the operator ticks several boxes.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The option value type (an enum or string).</typeparam>
|
||||
public partial class MultiSelectDropdown<TValue> where TValue : notnull
|
||||
{
|
||||
/// <summary>The options shown in the menu, in display order.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public IReadOnlyList<TValue> Items { get; set; } = Array.Empty<TValue>();
|
||||
|
||||
/// <summary>
|
||||
/// The caller-owned selection set. Mutated in place by <see cref="Toggle"/>.
|
||||
/// </summary>
|
||||
[Parameter, EditorRequired]
|
||||
public ICollection<TValue> Selected { get; set; } = default!;
|
||||
|
||||
/// <summary>Maps an option to its display label. Defaults to <c>ToString()</c>.</summary>
|
||||
[Parameter]
|
||||
public Func<TValue, string> Display { get; set; } = static v => v.ToString() ?? string.Empty;
|
||||
|
||||
/// <summary>Raised after each toggle, once <see cref="Selected"/> has been updated.</summary>
|
||||
[Parameter]
|
||||
public EventCallback SelectionChanged { get; set; }
|
||||
|
||||
/// <summary>Summary text shown on the toggle button when nothing is selected.</summary>
|
||||
[Parameter]
|
||||
public string AllLabel { get; set; } = "All";
|
||||
|
||||
/// <summary>Text shown in the menu when there are no options.</summary>
|
||||
[Parameter]
|
||||
public string EmptyText { get; set; } = "None available";
|
||||
|
||||
/// <summary><c>data-test</c> root for this control, its toggle and its options.</summary>
|
||||
[Parameter]
|
||||
public string DataTest { get; set; } = "multi-select";
|
||||
|
||||
private async Task Toggle(TValue item)
|
||||
{
|
||||
// ICollection.Remove returns false when the item was absent — that is the
|
||||
// "not currently selected" case, so add it. This is a plain toggle.
|
||||
if (!Selected.Remove(item))
|
||||
{
|
||||
Selected.Add(item);
|
||||
}
|
||||
|
||||
await SelectionChanged.InvokeAsync();
|
||||
}
|
||||
|
||||
private string Summary()
|
||||
{
|
||||
var count = Selected.Count;
|
||||
if (count == 0)
|
||||
{
|
||||
return AllLabel;
|
||||
}
|
||||
|
||||
if (count == 1)
|
||||
{
|
||||
// Prefer the single selection's label over a bare "1 selected".
|
||||
foreach (var item in Items)
|
||||
{
|
||||
if (Selected.Contains(item))
|
||||
{
|
||||
return Display(item);
|
||||
}
|
||||
}
|
||||
|
||||
// The one selected value is not in the current Items list (e.g. a Kind
|
||||
// narrowed out by a Channel change before the parent pruned it).
|
||||
return "1 selected";
|
||||
}
|
||||
|
||||
return $"{count} selected";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/* Compact multi-select dropdown. Tuned to sit inline with form-select-sm /
|
||||
form-control-sm controls in a filter row. */
|
||||
|
||||
.msd-toggle {
|
||||
min-width: 9rem;
|
||||
max-width: 15rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Keep a long option list from running off-screen — scroll within the menu. */
|
||||
.msd-menu {
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* The whole row is a <label> so a click anywhere toggles the checkbox; the
|
||||
menu stays open thanks to data-bs-auto-close="outside". */
|
||||
.msd-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Neutralise the default form-check-input top margin so the box lines up with
|
||||
the option text inside the dropdown-item. */
|
||||
.msd-check {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -26,6 +26,13 @@ public sealed record AuditEvent
|
||||
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
|
||||
public Guid? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Id of the originating script execution / inbound request — the universal
|
||||
/// per-run correlation value, distinct from <see cref="CorrelationId"/> (which
|
||||
/// is the per-operation lifecycle id).
|
||||
/// </summary>
|
||||
public Guid? ExecutionId { get; init; }
|
||||
|
||||
/// <summary>Site id where the action originated; null for central-direct events.</summary>
|
||||
public string? SourceSiteId { get; init; }
|
||||
|
||||
|
||||
@@ -27,6 +27,15 @@ public class Notification
|
||||
public string SourceSiteId { get; set; }
|
||||
public string? SourceInstanceId { get; set; }
|
||||
public string? SourceScript { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The originating script execution's <c>ExecutionId</c> (Audit Log #23). Carried from
|
||||
/// the site on the <see cref="Commons.Messages.Notification.NotificationSubmit"/> so the
|
||||
/// central dispatcher can stamp the same id onto its <c>NotifyDeliver</c> audit rows,
|
||||
/// correlating them with the site-emitted <c>NotifySend</c> row. Null for notifications
|
||||
/// submitted before the column existed, or raised outside a script-execution context.
|
||||
/// </summary>
|
||||
public Guid? OriginExecutionId { get; set; }
|
||||
public DateTimeOffset SiteEnqueuedAt { get; set; }
|
||||
|
||||
/// <summary>Central ingest time.</summary>
|
||||
|
||||
@@ -57,6 +57,20 @@ public interface ICachedCallLifecycleObserver
|
||||
/// <param name="OccurredAtUtc">When this attempt completed.</param>
|
||||
/// <param name="DurationMs">Duration of the attempt in milliseconds (null when not measured).</param>
|
||||
/// <param name="SourceInstanceId">Originating instance, when known.</param>
|
||||
/// <param name="ExecutionId">
|
||||
/// 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 <c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted and
|
||||
/// <c>CachedResolve</c> rows so they correlate with the rest of the run.
|
||||
/// <c>null</c> for rows buffered before Task 4 (back-compat).
|
||||
/// </param>
|
||||
/// <param name="SourceScript">
|
||||
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
|
||||
/// threaded alongside <paramref name="ExecutionId"/> so the retry-loop audit
|
||||
/// rows carry the same <c>SourceScript</c> provenance the script-side cached
|
||||
/// rows already do. <c>null</c> when not known.
|
||||
/// </param>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Coarse outcome of one cached-call delivery attempt, observed from inside
|
||||
|
||||
@@ -29,11 +29,24 @@ public interface IDatabaseGateway
|
||||
/// <c>null</c> — when omitted the S&F engine mints a fresh GUID and no
|
||||
/// M3 telemetry is correlated (pre-M3 caller behaviour).
|
||||
/// </param>
|
||||
/// <param name="executionId">
|
||||
/// 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. <c>null</c> when not threaded.
|
||||
/// </param>
|
||||
/// <param name="sourceScript">
|
||||
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
|
||||
/// threaded onto the buffered S&F message alongside
|
||||
/// <paramref name="executionId"/>. <c>null</c> when not known.
|
||||
/// </param>
|
||||
Task CachedWriteAsync(
|
||||
string connectionName,
|
||||
string sql,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
string? originInstanceName = null,
|
||||
CancellationToken cancellationToken = default,
|
||||
TrackedOperationId? trackedOperationId = null);
|
||||
TrackedOperationId? trackedOperationId = null,
|
||||
Guid? executionId = null,
|
||||
string? sourceScript = null);
|
||||
}
|
||||
|
||||
@@ -30,13 +30,26 @@ public interface IExternalSystemClient
|
||||
/// M3 telemetry is correlated (the legacy behaviour pre-M3 callers rely
|
||||
/// on).
|
||||
/// </param>
|
||||
/// <param name="executionId">
|
||||
/// 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. <c>null</c> when not threaded.
|
||||
/// </param>
|
||||
/// <param name="sourceScript">
|
||||
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
|
||||
/// threaded onto the buffered S&F message alongside
|
||||
/// <paramref name="executionId"/>. <c>null</c> when not known.
|
||||
/// </param>
|
||||
Task<ExternalCallResult> CachedCallAsync(
|
||||
string systemName,
|
||||
string methodName,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
string? originInstanceName = null,
|
||||
CancellationToken cancellationToken = default,
|
||||
TrackedOperationId? trackedOperationId = null);
|
||||
TrackedOperationId? trackedOperationId = null,
|
||||
Guid? executionId = null,
|
||||
string? sourceScript = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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 <see cref="NotificationSubmitAck"/> is received.
|
||||
/// </summary>
|
||||
/// <param name="OriginExecutionId">
|
||||
/// The originating script execution's <c>ExecutionId</c> (Audit Log #23). Stamped at
|
||||
/// <c>Notify.Send</c> time and carried, inside the serialized payload, through the site
|
||||
/// store-and-forward buffer so the central dispatcher can echo it onto the
|
||||
/// <c>NotifyDeliver</c> audit rows. Additive trailing member — null for messages built
|
||||
/// before the field existed, or for notifications raised outside a script execution.
|
||||
/// </param>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Central -> Site: ack sent after the notification row is persisted.
|
||||
|
||||
@@ -11,7 +11,9 @@ namespace ScadaLink.Commons.Types.Audit;
|
||||
/// dimension (translated to a SQL <c>IN (…)</c>). Time bounds are half-open in
|
||||
/// the spec sense — <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is
|
||||
/// inclusive of the upper bound; the repository SQL uses <c>>=</c> / <c><=</c>
|
||||
/// respectively. All filter dimensions are AND-combined with one another.
|
||||
/// respectively. All filter dimensions are AND-combined with one another. The
|
||||
/// single-value <see cref="CorrelationId"/> and <see cref="ExecutionId"/>
|
||||
/// dimensions constrain on equality when set.
|
||||
/// </summary>
|
||||
public sealed record AuditLogQueryFilter(
|
||||
IReadOnlyList<AuditChannel>? 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);
|
||||
|
||||
@@ -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<AuditChannel>(dto.Channel),
|
||||
Kind = Enum.Parse<AuditKind>(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),
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "execution_id" field.</summary>
|
||||
public const int ExecutionIdFieldNumber = 20;
|
||||
private string executionId_ = "";
|
||||
/// <summary>
|
||||
/// empty string represents null
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,10 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEve
|
||||
.HasFilter("[CorrelationId] IS NOT NULL")
|
||||
.HasDatabaseName("IX_AuditLog_CorrelationId");
|
||||
|
||||
builder.HasIndex(e => 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");
|
||||
|
||||
@@ -47,6 +47,10 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration<Notifica
|
||||
|
||||
builder.Property(n => 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 });
|
||||
|
||||
1626
src/ScadaLink.ConfigurationDatabase/Migrations/20260521184044_AddAuditLogExecutionId.Designer.cs
generated
Normal file
1626
src/ScadaLink.ConfigurationDatabase/Migrations/20260521184044_AddAuditLogExecutionId.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the universal <c>ExecutionId</c> correlation column to the centralized
|
||||
/// <c>AuditLog</c> table (#23). <c>ExecutionId</c> identifies the originating
|
||||
/// script execution / inbound request and is distinct from the per-operation
|
||||
/// <c>CorrelationId</c>.
|
||||
///
|
||||
/// The change is purely additive:
|
||||
/// 1. <c>ExecutionId uniqueidentifier NULL</c> is added with no default, so the
|
||||
/// operation is a metadata-only <c>ALTER TABLE … ADD</c> — it does NOT
|
||||
/// rewrite the monthly-partitioned <c>AuditLog</c> table, and historical
|
||||
/// rows stay <c>NULL</c> (no backfill).
|
||||
/// 2. <c>IX_AuditLog_Execution</c> is created via raw SQL so it lands on the
|
||||
/// <c>ps_AuditLog_Month(OccurredAtUtc)</c> partition scheme, matching every
|
||||
/// other <c>IX_AuditLog_*</c> index. Keeping it partition-aligned preserves
|
||||
/// the partition-switch purge path (see AuditLogRepository.SwitchOutPartitionAsync).
|
||||
/// </summary>
|
||||
public partial class AddAuditLogExecutionId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
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);");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1629
src/ScadaLink.ConfigurationDatabase/Migrations/20260521193048_AddNotificationOriginExecutionId.Designer.cs
generated
Normal file
1629
src/ScadaLink.ConfigurationDatabase/Migrations/20260521193048_AddNotificationOriginExecutionId.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the <c>OriginExecutionId</c> correlation column to the central
|
||||
/// <c>Notifications</c> table (#21). It carries the originating script execution's
|
||||
/// <c>ExecutionId</c> from the site so the dispatcher can echo it onto the
|
||||
/// <c>NotifyDeliver</c> audit rows (#23), linking them to the site's <c>NotifySend</c>
|
||||
/// row for the same run.
|
||||
///
|
||||
/// The change is purely additive: <c>OriginExecutionId uniqueidentifier NULL</c> is
|
||||
/// added with no default, so the operation is a metadata-only <c>ALTER TABLE … ADD</c>.
|
||||
/// Unlike <c>AuditLog</c>, the <c>Notifications</c> table is NOT partitioned, so a
|
||||
/// plain <c>ADD</c> is fine. No index is created — the column is never a query
|
||||
/// predicate, only copied onto audit events. Historical rows stay <c>NULL</c>.
|
||||
/// </summary>
|
||||
public partial class AddNotificationOriginExecutionId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "OriginExecutionId",
|
||||
table: "Notifications",
|
||||
type: "uniqueidentifier",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OriginExecutionId",
|
||||
table: "Notifications");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<Guid?>("ExecutionId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("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");
|
||||
@@ -780,6 +787,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
b.Property<DateTimeOffset?>("NextAttemptAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("OriginExecutionId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("ResolvedTargets")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
||||
@@ -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,10 @@ 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];
|
||||
|
||||
|
||||
@@ -84,7 +84,9 @@ public class DatabaseGateway : IDatabaseGateway
|
||||
IReadOnlyDictionary<string, object?>? 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -86,7 +86,9 @@ public class ExternalSystemClient : IExternalSystemClient
|
||||
IReadOnlyDictionary<string, object?>? 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);
|
||||
}
|
||||
|
||||
@@ -145,6 +145,21 @@ public sealed class AuditWriteMiddleware
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiInbound,
|
||||
Kind = kind,
|
||||
// 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
|
||||
// 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.
|
||||
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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -30,6 +30,13 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
private const int FallbackMaxRetries = 10;
|
||||
private static readonly TimeSpan FallbackRetryDelay = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Audit <c>Actor</c> stamped on central-dispatch (<c>NotifyDeliver</c>) rows.
|
||||
/// The Actor-column spec assigns central-originated audit rows a system
|
||||
/// identity — there is no per-call authenticated user at dispatch time.
|
||||
/// </summary>
|
||||
private const string SystemActor = "system";
|
||||
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly NotificationOutboxOptions _options;
|
||||
private readonly ICentralAuditWriter _auditWriter;
|
||||
@@ -482,6 +489,10 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
/// parses the notification's id as a Guid; sites generate the id with
|
||||
/// <c>Guid.NewGuid().ToString("N")</c> so the parse always succeeds, but
|
||||
/// a non-Guid id is recorded as null rather than crashing the dispatcher.
|
||||
/// <see cref="AuditEvent.ExecutionId"/> is copied straight from
|
||||
/// <see cref="Notification.OriginExecutionId"/> so the dispatcher's
|
||||
/// <c>NotifyDeliver</c> rows carry the same per-run id as the site's
|
||||
/// <c>NotifySend</c> row (Audit Log #23).
|
||||
/// </summary>
|
||||
private static AuditEvent BuildNotifyDeliverEvent(
|
||||
Notification notification,
|
||||
@@ -500,12 +511,20 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
Channel = AuditChannel.Notification,
|
||||
Kind = AuditKind.NotifyDeliver,
|
||||
CorrelationId = correlationId,
|
||||
// Central dispatch — no authenticated actor (the originating
|
||||
// script's identity is captured on the upstream NotifySend row).
|
||||
Actor = null,
|
||||
// Central dispatch — a system identity per the Actor-column spec;
|
||||
// there is no per-call authenticated user here. The originating
|
||||
// script is still captured on SourceScript (and on the upstream
|
||||
// NotifySend row).
|
||||
Actor = SystemActor,
|
||||
SourceSiteId = notification.SourceSiteId,
|
||||
SourceInstanceId = notification.SourceInstanceId,
|
||||
SourceScript = notification.SourceScript,
|
||||
// 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,
|
||||
@@ -932,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.
|
||||
|
||||
@@ -37,9 +37,13 @@ internal sealed class AuditingDbCommand : DbCommand
|
||||
private readonly string _siteId;
|
||||
private readonly string _instanceName;
|
||||
private readonly string? _sourceScript;
|
||||
private readonly Guid _executionId;
|
||||
private readonly ILogger _logger;
|
||||
private DbConnection? _wrappingConnection;
|
||||
|
||||
// Parameter ordering: executionId sits immediately after the ILogger,
|
||||
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
|
||||
// DatabaseHelper, AuditingDbConnection).
|
||||
public AuditingDbCommand(
|
||||
DbCommand inner,
|
||||
IAuditWriter auditWriter,
|
||||
@@ -47,7 +51,8 @@ internal sealed class AuditingDbCommand : DbCommand
|
||||
string siteId,
|
||||
string instanceName,
|
||||
string? sourceScript,
|
||||
ILogger logger)
|
||||
ILogger logger,
|
||||
Guid executionId)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
|
||||
@@ -56,6 +61,7 @@ internal sealed class AuditingDbCommand : DbCommand
|
||||
_instanceName = instanceName ?? string.Empty;
|
||||
_sourceScript = sourceScript;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_executionId = executionId;
|
||||
}
|
||||
|
||||
// -- Forwarded surface ------------------------------------------------
|
||||
@@ -426,11 +432,19 @@ internal sealed class AuditingDbCommand : DbCommand
|
||||
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
||||
Channel = AuditChannel.DbOutbound,
|
||||
Kind = AuditKind.DbWrite,
|
||||
// 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,
|
||||
Actor = null,
|
||||
// Outbound channel: per the Audit Log Actor-column spec the actor is
|
||||
// the calling script. Null when no single script owns the call
|
||||
// (e.g. a shared script running inline).
|
||||
Actor = _sourceScript,
|
||||
Target = target,
|
||||
Status = status,
|
||||
HttpStatus = null,
|
||||
|
||||
@@ -36,8 +36,12 @@ internal sealed class AuditingDbConnection : DbConnection
|
||||
private readonly string _siteId;
|
||||
private readonly string _instanceName;
|
||||
private readonly string? _sourceScript;
|
||||
private readonly Guid _executionId;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
// Parameter ordering: executionId sits immediately after the ILogger,
|
||||
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
|
||||
// DatabaseHelper, AuditingDbCommand).
|
||||
public AuditingDbConnection(
|
||||
DbConnection inner,
|
||||
IAuditWriter auditWriter,
|
||||
@@ -45,7 +49,8 @@ internal sealed class AuditingDbConnection : DbConnection
|
||||
string siteId,
|
||||
string instanceName,
|
||||
string? sourceScript,
|
||||
ILogger logger)
|
||||
ILogger logger,
|
||||
Guid executionId)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
|
||||
@@ -54,6 +59,7 @@ internal sealed class AuditingDbConnection : DbConnection
|
||||
_instanceName = instanceName ?? string.Empty;
|
||||
_sourceScript = sourceScript;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_executionId = executionId;
|
||||
}
|
||||
|
||||
// ConnectionString is settable on DbConnection — forward both halves.
|
||||
@@ -92,7 +98,8 @@ internal sealed class AuditingDbConnection : DbConnection
|
||||
_siteId,
|
||||
_instanceName,
|
||||
_sourceScript,
|
||||
_logger);
|
||||
_logger,
|
||||
_executionId);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
|
||||
@@ -105,6 +105,24 @@ public class ScriptRuntimeContext
|
||||
/// </summary>
|
||||
private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23: the per-execution id for this script run. Every
|
||||
/// trust-boundary audit row emitted by this script execution
|
||||
/// (sync <c>ApiCall</c>/<c>DbWrite</c>, cached-call lifecycle rows,
|
||||
/// <c>NotifySend</c>) is stamped into <c>AuditEvent.ExecutionId</c> with
|
||||
/// this value so all the rows from one script run can be correlated
|
||||
/// together — independently of the per-operation
|
||||
/// <c>AuditEvent.CorrelationId</c>.
|
||||
/// </summary>
|
||||
private readonly Guid _executionId;
|
||||
|
||||
/// <param name="executionId">
|
||||
/// 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 into <c>AuditEvent.ExecutionId</c> on every
|
||||
/// trust-boundary audit row this execution emits.
|
||||
/// </param>
|
||||
public ScriptRuntimeContext(
|
||||
IActorRef instanceActor,
|
||||
IActorRef self,
|
||||
@@ -122,7 +140,8 @@ public class ScriptRuntimeContext
|
||||
string? sourceScript = null,
|
||||
IAuditWriter? auditWriter = null,
|
||||
IOperationTrackingStore? operationTrackingStore = null,
|
||||
ICachedCallTelemetryForwarder? cachedForwarder = null)
|
||||
ICachedCallTelemetryForwarder? cachedForwarder = null,
|
||||
Guid? executionId = null)
|
||||
{
|
||||
_instanceActor = instanceActor;
|
||||
_self = self;
|
||||
@@ -141,6 +160,7 @@ public class ScriptRuntimeContext
|
||||
_auditWriter = auditWriter;
|
||||
_operationTrackingStore = operationTrackingStore;
|
||||
_cachedForwarder = cachedForwarder;
|
||||
_executionId = executionId ?? Guid.NewGuid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -241,7 +261,7 @@ public class ScriptRuntimeContext
|
||||
/// ExternalSystem.CachedCall("systemName", "methodName", params)
|
||||
/// </summary>
|
||||
public ExternalSystemHelper ExternalSystem => new(
|
||||
_externalSystemClient, _instanceName, _logger, _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);
|
||||
@@ -255,6 +275,7 @@ public class ScriptRuntimeContext
|
||||
_databaseGateway,
|
||||
_instanceName,
|
||||
_logger,
|
||||
_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
|
||||
@@ -281,7 +302,7 @@ public class ScriptRuntimeContext
|
||||
/// </remarks>
|
||||
public NotifyHelper Notify => new(
|
||||
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger,
|
||||
_auditWriter);
|
||||
_executionId, _auditWriter);
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (M3): site-local tracking-status API for cached operations.
|
||||
@@ -362,6 +383,7 @@ public class ScriptRuntimeContext
|
||||
private readonly IExternalSystemClient? _client;
|
||||
private readonly string _instanceName;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Guid _executionId;
|
||||
private readonly IAuditWriter? _auditWriter;
|
||||
private readonly string _siteId;
|
||||
private readonly string? _sourceScript;
|
||||
@@ -370,10 +392,18 @@ public class ScriptRuntimeContext
|
||||
// Internal constructor for tests living in ScadaLink.SiteRuntime.Tests
|
||||
// (via InternalsVisibleTo). Production sites resolve the helper through
|
||||
// ScriptRuntimeContext.ExternalSystem.
|
||||
//
|
||||
// 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
|
||||
// required-after-optional compile error, so the post-logger slot is the
|
||||
// one consistent position that compiles cleanly everywhere.
|
||||
internal ExternalSystemHelper(
|
||||
IExternalSystemClient? client,
|
||||
string instanceName,
|
||||
ILogger logger,
|
||||
Guid executionId,
|
||||
IAuditWriter? auditWriter = null,
|
||||
string siteId = "",
|
||||
string? sourceScript = null,
|
||||
@@ -382,6 +412,7 @@ public class ScriptRuntimeContext
|
||||
_client = client;
|
||||
_instanceName = instanceName;
|
||||
_logger = logger;
|
||||
_executionId = executionId;
|
||||
_auditWriter = auditWriter;
|
||||
_siteId = siteId;
|
||||
_sourceScript = sourceScript;
|
||||
@@ -420,7 +451,7 @@ public class ScriptRuntimeContext
|
||||
{
|
||||
var elapsedMs = (int)((Stopwatch.GetTimestamp() - startTicks)
|
||||
* 1000d / Stopwatch.Frequency);
|
||||
EmitCallAudit(systemName, methodName, occurredAtUtc, elapsedMs, result, thrown);
|
||||
EmitCallAudit(systemName, methodName, occurredAtUtc, elapsedMs, result, thrown, parameters);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,7 +489,7 @@ public class ScriptRuntimeContext
|
||||
// Submitted row even if the immediate-delivery attempt happens to
|
||||
// resolve before this method returns.
|
||||
await EmitCachedSubmitTelemetryAsync(
|
||||
systemName, methodName, target, trackedId, occurredAtUtc, cancellationToken)
|
||||
systemName, methodName, target, trackedId, occurredAtUtc, parameters, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Hand off to the existing cached-call path. The TrackedOperationId
|
||||
@@ -482,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)
|
||||
{
|
||||
@@ -503,7 +539,7 @@ public class ScriptRuntimeContext
|
||||
if (result is { WasBuffered: false })
|
||||
{
|
||||
await EmitImmediateTerminalTelemetryAsync(
|
||||
systemName, methodName, target, trackedId, result, cancellationToken)
|
||||
systemName, methodName, target, trackedId, result, parameters, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -521,6 +557,7 @@ public class ScriptRuntimeContext
|
||||
string target,
|
||||
TrackedOperationId trackedId,
|
||||
DateTime occurredAtUtc,
|
||||
IReadOnlyDictionary<string, object?>? parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_cachedForwarder == null)
|
||||
@@ -538,12 +575,18 @@ 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,
|
||||
Target = target,
|
||||
Status = AuditStatus.Submitted,
|
||||
// Submit precedes the call — request args only, no response yet.
|
||||
RequestSummary = SerializeRequest(parameters),
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
},
|
||||
Operational: new SiteCallOperational(
|
||||
@@ -599,6 +642,7 @@ public class ScriptRuntimeContext
|
||||
string target,
|
||||
TrackedOperationId trackedId,
|
||||
ExternalCallResult result,
|
||||
IReadOnlyDictionary<string, object?>? parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_cachedForwarder == null)
|
||||
@@ -645,7 +689,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,
|
||||
@@ -653,6 +700,8 @@ public class ScriptRuntimeContext
|
||||
Status = AuditStatus.Attempted,
|
||||
HttpStatus = httpStatus,
|
||||
ErrorMessage = result.Success ? null : result.ErrorMessage,
|
||||
RequestSummary = SerializeRequest(parameters),
|
||||
ResponseSummary = result.ResponseJson,
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
},
|
||||
Operational: new SiteCallOperational(
|
||||
@@ -704,7 +753,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,
|
||||
@@ -712,6 +764,8 @@ public class ScriptRuntimeContext
|
||||
Status = auditTerminalStatus,
|
||||
HttpStatus = httpStatus,
|
||||
ErrorMessage = result.Success ? null : result.ErrorMessage,
|
||||
RequestSummary = SerializeRequest(parameters),
|
||||
ResponseSummary = result.ResponseJson,
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
},
|
||||
Operational: new SiteCallOperational(
|
||||
@@ -762,7 +816,8 @@ public class ScriptRuntimeContext
|
||||
DateTime occurredAtUtc,
|
||||
int durationMs,
|
||||
ExternalCallResult? result,
|
||||
Exception? thrown)
|
||||
Exception? thrown,
|
||||
IReadOnlyDictionary<string, object?>? parameters)
|
||||
{
|
||||
if (_auditWriter == null)
|
||||
{
|
||||
@@ -772,7 +827,8 @@ public class ScriptRuntimeContext
|
||||
AuditEvent evt;
|
||||
try
|
||||
{
|
||||
evt = BuildCallAuditEvent(systemName, methodName, occurredAtUtc, durationMs, result, thrown);
|
||||
evt = BuildCallAuditEvent(
|
||||
systemName, methodName, occurredAtUtc, durationMs, result, thrown, parameters);
|
||||
}
|
||||
catch (Exception buildEx)
|
||||
{
|
||||
@@ -828,7 +884,8 @@ public class ScriptRuntimeContext
|
||||
DateTime occurredAtUtc,
|
||||
int durationMs,
|
||||
ExternalCallResult? result,
|
||||
Exception? thrown)
|
||||
Exception? thrown,
|
||||
IReadOnlyDictionary<string, object?>? parameters)
|
||||
{
|
||||
// Status: Delivered on a Success result; Failed otherwise (the
|
||||
// ExternalSystemClient already maps HTTP non-2xx + transient
|
||||
@@ -871,24 +928,60 @@ public class ScriptRuntimeContext
|
||||
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
// 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,
|
||||
Actor = null,
|
||||
// Outbound channel: per the Audit Log Actor-column spec the actor
|
||||
// is the calling script. Null when no single script owns the call
|
||||
// (e.g. a shared script running inline).
|
||||
Actor = _sourceScript,
|
||||
Target = $"{systemName}.{methodName}",
|
||||
Status = status,
|
||||
HttpStatus = httpStatus,
|
||||
DurationMs = durationMs,
|
||||
ErrorMessage = errorMessage,
|
||||
ErrorDetail = errorDetail,
|
||||
RequestSummary = null,
|
||||
ResponseSummary = null,
|
||||
// Payload capture: the request arguments and the response body.
|
||||
// The audit writer's payload filter applies the configured size
|
||||
// cap and header/secret redaction downstream — the emitter just
|
||||
// hands over the raw values.
|
||||
RequestSummary = SerializeRequest(parameters),
|
||||
ResponseSummary = result?.ResponseJson,
|
||||
PayloadTruncated = false,
|
||||
Extra = null,
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialises the outbound-call argument dictionary into the JSON
|
||||
/// <c>RequestSummary</c> stamped on <c>ApiOutbound</c> audit rows.
|
||||
/// Returns <c>null</c> for a null/empty argument set. Serialization
|
||||
/// failure is swallowed (returns <c>null</c>) — a payload that cannot be
|
||||
/// summarised must never abort the best-effort audit emission.
|
||||
/// </summary>
|
||||
private static string? SerializeRequest(IReadOnlyDictionary<string, object?>? parameters)
|
||||
{
|
||||
if (parameters is null || parameters.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Serialize(parameters);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -907,6 +1000,7 @@ public class ScriptRuntimeContext
|
||||
private readonly IDatabaseGateway? _gateway;
|
||||
private readonly string _instanceName;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Guid _executionId;
|
||||
private readonly string _siteId;
|
||||
private readonly string? _sourceScript;
|
||||
private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
|
||||
@@ -923,10 +1017,15 @@ public class ScriptRuntimeContext
|
||||
/// </summary>
|
||||
private readonly IAuditWriter? _auditWriter;
|
||||
|
||||
// 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.
|
||||
internal DatabaseHelper(
|
||||
IDatabaseGateway? gateway,
|
||||
string instanceName,
|
||||
ILogger logger,
|
||||
Guid executionId,
|
||||
IAuditWriter? auditWriter = null,
|
||||
string siteId = "",
|
||||
string? sourceScript = null,
|
||||
@@ -935,6 +1034,7 @@ public class ScriptRuntimeContext
|
||||
_gateway = gateway;
|
||||
_instanceName = instanceName;
|
||||
_logger = logger;
|
||||
_executionId = executionId;
|
||||
_auditWriter = auditWriter;
|
||||
_siteId = siteId;
|
||||
_sourceScript = sourceScript;
|
||||
@@ -969,7 +1069,8 @@ public class ScriptRuntimeContext
|
||||
siteId: _siteId,
|
||||
instanceName: _instanceName,
|
||||
sourceScript: _sourceScript,
|
||||
logger: _logger);
|
||||
logger: _logger,
|
||||
executionId: _executionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1000,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)
|
||||
@@ -1036,7 +1142,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,
|
||||
@@ -1098,6 +1207,12 @@ public class ScriptRuntimeContext
|
||||
private readonly TimeSpan _askTimeout;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23: the per-execution id for this script run, stamped
|
||||
/// into <c>AuditEvent.ExecutionId</c> on the <c>NotifySend</c> row.
|
||||
/// </summary>
|
||||
private readonly Guid _executionId;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the
|
||||
/// <c>Notification</c>/<c>NotifySend</c> row produced when the script
|
||||
@@ -1108,6 +1223,8 @@ public class ScriptRuntimeContext
|
||||
/// </summary>
|
||||
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,
|
||||
@@ -1116,6 +1233,7 @@ public class ScriptRuntimeContext
|
||||
string? sourceScript,
|
||||
TimeSpan askTimeout,
|
||||
ILogger logger,
|
||||
Guid executionId,
|
||||
IAuditWriter? auditWriter = null)
|
||||
{
|
||||
_storeAndForward = storeAndForward;
|
||||
@@ -1125,6 +1243,7 @@ public class ScriptRuntimeContext
|
||||
_sourceScript = sourceScript;
|
||||
_askTimeout = askTimeout;
|
||||
_logger = logger;
|
||||
_executionId = executionId;
|
||||
_auditWriter = auditWriter;
|
||||
}
|
||||
|
||||
@@ -1135,6 +1254,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);
|
||||
@@ -1212,6 +1334,12 @@ public class ScriptRuntimeContext
|
||||
private readonly string? _sourceScript;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23: the per-execution id for this script run, stamped
|
||||
/// into <c>AuditEvent.ExecutionId</c> on the <c>NotifySend</c> row.
|
||||
/// </summary>
|
||||
private readonly Guid _executionId;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the
|
||||
/// <c>Notification</c>/<c>NotifySend</c> row written immediately after
|
||||
@@ -1227,6 +1355,7 @@ public class ScriptRuntimeContext
|
||||
string instanceName,
|
||||
string? sourceScript,
|
||||
ILogger logger,
|
||||
Guid executionId,
|
||||
IAuditWriter? auditWriter = null)
|
||||
{
|
||||
_listName = listName;
|
||||
@@ -1235,6 +1364,7 @@ public class ScriptRuntimeContext
|
||||
_instanceName = instanceName;
|
||||
_sourceScript = sourceScript;
|
||||
_logger = logger;
|
||||
_executionId = executionId;
|
||||
_auditWriter = auditWriter;
|
||||
}
|
||||
|
||||
@@ -1277,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);
|
||||
|
||||
@@ -1351,11 +1486,17 @@ 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,
|
||||
Actor = null,
|
||||
// Outbound channel: per the Audit Log Actor-column spec the
|
||||
// actor is the calling script. Null when no single script
|
||||
// owns the call (e.g. a shared script running inline).
|
||||
Actor = _sourceScript,
|
||||
Target = _listName,
|
||||
Status = AuditStatus.Submitted,
|
||||
HttpStatus = null,
|
||||
|
||||
@@ -55,4 +55,25 @@ public class StoreAndForwardMessage
|
||||
/// WP-13: Messages are NOT cleared when instance is deleted.
|
||||
/// </summary>
|
||||
public string? OriginInstanceName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
|
||||
/// per-run correlation id, threaded from <c>ScriptRuntimeContext</c> 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
|
||||
/// (<c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted, <c>CachedResolve</c>).
|
||||
/// <c>null</c> 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).
|
||||
/// </summary>
|
||||
public Guid? ExecutionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
|
||||
/// threaded alongside <see cref="ExecutionId"/> from the cached-call enqueue
|
||||
/// path so the retry-loop audit rows carry the same <c>SourceScript</c>
|
||||
/// provenance the script-side cached rows already carry. <c>null</c> when not
|
||||
/// known (non-cached categories, pre-migration rows).
|
||||
/// </summary>
|
||||
public string? SourceScript { get; set; }
|
||||
}
|
||||
|
||||
@@ -175,6 +175,18 @@ public class StoreAndForwardService
|
||||
/// it is the buffered row's <see cref="StoreAndForwardMessage.Id"/>, it is carried
|
||||
/// inside the payload, and it is the id the forwarder submits to central.
|
||||
/// </param>
|
||||
/// <param name="executionId">
|
||||
/// 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. <c>null</c> for callers (notifications,
|
||||
/// pre-Task-4 callers) that do not supply one.
|
||||
/// </param>
|
||||
/// <param name="sourceScript">
|
||||
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
|
||||
/// threaded onto the buffered row alongside <paramref name="executionId"/>
|
||||
/// so the retry-loop audit rows carry the same provenance the script-side
|
||||
/// cached rows do. <c>null</c> when not known.
|
||||
/// </param>
|
||||
public async Task<StoreAndForwardResult> 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)
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ExecutionId Task 4): adds a column to <c>sf_messages</c>
|
||||
/// only when it is not already present. SQLite lacks <c>ADD COLUMN IF NOT
|
||||
/// EXISTS</c>, so the schema is probed via <c>PRAGMA table_info</c> first.
|
||||
/// Idempotent — safe to run on every <see cref="InitializeAsync"/>.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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,9 +493,35 @@ 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).
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ExecutionId Task 4): defensively reads the
|
||||
/// <c>execution_id</c> column. A <c>null</c> value (legacy pre-migration
|
||||
/// rows) and a malformed non-null value both yield <c>null</c> — a corrupt
|
||||
/// id must not throw and abort the retry sweep, which reads many rows.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
||||
client,
|
||||
instanceName: "Plant.Pump42",
|
||||
NullLogger.Instance,
|
||||
Guid.NewGuid(),
|
||||
auditWriter: writer,
|
||||
siteId: "site-77",
|
||||
sourceScript: "ScriptActor:Sync",
|
||||
@@ -193,6 +194,7 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
||||
client,
|
||||
instanceName: "Plant.Pump42",
|
||||
NullLogger.Instance,
|
||||
Guid.NewGuid(),
|
||||
auditWriter: writer,
|
||||
siteId: "site-77",
|
||||
sourceScript: "ScriptActor:Cached",
|
||||
@@ -243,6 +245,7 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
|
||||
gateway,
|
||||
instanceName,
|
||||
NullLogger.Instance,
|
||||
Guid.NewGuid(),
|
||||
auditWriter: writer,
|
||||
siteId: "site-77",
|
||||
sourceScript: "ScriptActor:Db",
|
||||
|
||||
@@ -157,6 +157,7 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
|
||||
gateway,
|
||||
InstanceName,
|
||||
NullLogger.Instance,
|
||||
Guid.NewGuid(),
|
||||
auditWriter: writer,
|
||||
siteId: siteId,
|
||||
sourceScript: SourceScript,
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 — ExecutionId end-to-end correlation suite verifying the
|
||||
/// universal per-run correlation promise: <b>every audit row produced by one
|
||||
/// script execution carries the same non-null <see cref="AuditEvent.ExecutionId"/></b>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the integration-level counterpart to the unit-level
|
||||
/// <c>ExecutionCorrelationContextTests</c> in <c>ScadaLink.SiteRuntime.Tests</c>:
|
||||
/// 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
|
||||
/// <see cref="SqliteAuditWriter"/> site hot-path, the real
|
||||
/// <see cref="SiteAuditTelemetryActor"/> drain loop, the real
|
||||
/// <see cref="AuditLogIngestActor"/>, and the real <see cref="AuditLogRepository"/>
|
||||
/// over the per-class <see cref="MsSqlMigrationFixture"/> MSSQL database — then
|
||||
/// reads the rows back from the central store and asserts the shared id.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Composes the same pipeline as the M2 <see cref="SyncCallEmissionEndToEndTests"/>
|
||||
/// and the M4 <see cref="DatabaseSyncEmissionEndToEndTests"/>: an in-memory
|
||||
/// <see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
|
||||
/// <see cref="FallbackAuditWriter"/> on the site, drained by a real
|
||||
/// <see cref="SiteAuditTelemetryActor"/> through the shared
|
||||
/// <see cref="DirectActorSiteStreamAuditClient"/> stub that short-circuits the
|
||||
/// gRPC wire and Asks the central ingest actor. The production
|
||||
/// <see cref="ScriptRuntimeContext"/> is driven directly: one context performs
|
||||
/// two distinct trust-boundary actions — a sync <c>ExternalSystem.Call</c> and a
|
||||
/// sync <c>Database</c> write — so the two emitted audit rows originate from one
|
||||
/// execution. Each test uses a unique <c>ExecutionId</c> + <c>SourceSiteId</c>
|
||||
/// (Guid suffixes) so concurrent tests sharing the MSSQL fixture don't interfere.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class ExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
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<ScadaLinkDbContext>()
|
||||
.UseSqlServer(_fixture.ConnectionString)
|
||||
.Options;
|
||||
return new ScadaLinkDbContext(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>live</c> connection is what the
|
||||
/// stub gateway hands back to the auditing wrapper. Mirrors
|
||||
/// <c>DatabaseSyncEmissionEndToEndTests.NewInMemoryDb</c>.
|
||||
/// </summary>
|
||||
private static SqliteConnection NewInMemoryDb(out SqliteConnection keepAlive)
|
||||
{
|
||||
var dbName = $"db-{Guid.NewGuid():N}";
|
||||
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
|
||||
|
||||
keepAlive = new SqliteConnection(connStr);
|
||||
keepAlive.Open();
|
||||
using (var seed = keepAlive.CreateCommand())
|
||||
{
|
||||
seed.CommandText =
|
||||
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);";
|
||||
seed.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
var live = new SqliteConnection(connStr);
|
||||
live.Open();
|
||||
return live;
|
||||
}
|
||||
|
||||
private static SqliteAuditWriter CreateInMemorySqliteWriter() =>
|
||||
new(
|
||||
Options.Create(new SqliteAuditWriterOptions
|
||||
{
|
||||
DatabasePath = "ignored",
|
||||
BatchSize = 64,
|
||||
ChannelCapacity = 1024,
|
||||
}),
|
||||
NullLogger<SqliteAuditWriter>.Instance,
|
||||
connectionStringOverride:
|
||||
$"Data Source=file:auditlog-execid-{Guid.NewGuid():N}?mode=memory&cache=shared");
|
||||
|
||||
private static IOptions<SiteAuditTelemetryOptions> FastTelemetryOptions() =>
|
||||
Options.Create(new SiteAuditTelemetryOptions
|
||||
{
|
||||
BatchSize = 256,
|
||||
// 1s on both intervals so the initial scheduled tick fires quickly
|
||||
// — drains the SQLite Pending 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<AuditLogIngestActor>.Instance)));
|
||||
|
||||
private IActorRef CreateTelemetryActor(
|
||||
ISiteAuditQueue queue,
|
||||
ISiteStreamAuditClient client) =>
|
||||
Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor(
|
||||
queue,
|
||||
client,
|
||||
FastTelemetryOptions(),
|
||||
NullLogger<SiteAuditTelemetryActor>.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<FallbackAuditWriter>.Instance);
|
||||
var stubClient = new DirectActorSiteStreamAuditClient(ingestActor);
|
||||
CreateTelemetryActor(sqliteWriter, stubClient);
|
||||
|
||||
// Outbound API client — one successful CallAsync, one audit row.
|
||||
var externalClient = Substitute.For<IExternalSystemClient>();
|
||||
externalClient
|
||||
.CallAsync("ERP", "GetOrder", Arg.Any<IReadOnlyDictionary<string, object?>?>(), Arg.Any<CancellationToken>())
|
||||
.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<IDatabaseGateway>();
|
||||
gateway.GetConnectionAsync(ConnectionName, Arg.Any<CancellationToken>())
|
||||
.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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a production <see cref="ScriptRuntimeContext"/> wired with the
|
||||
/// outbound external-system client, the database gateway and the audit
|
||||
/// writer, stamped with an explicit <paramref name="executionId"/>. The
|
||||
/// actor refs are <see cref="ActorRefs.Nobody"/> — the ExternalSystem /
|
||||
/// Database helpers exercised here never touch them.
|
||||
/// </summary>
|
||||
private static ScriptRuntimeContext CreateScriptContext(
|
||||
IExternalSystemClient externalSystemClient,
|
||||
IDatabaseGateway databaseGateway,
|
||||
IAuditWriter auditWriter,
|
||||
string siteId,
|
||||
Guid executionId)
|
||||
{
|
||||
var compilationService = new ScriptCompilationService(
|
||||
NullLogger<ScriptCompilationService>.Instance);
|
||||
var sharedScriptLibrary = new SharedScriptLibrary(
|
||||
compilationService, NullLogger<SharedScriptLibrary>.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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -41,9 +43,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 +59,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 +67,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));
|
||||
|
||||
@@ -125,4 +127,122 @@ public class SqliteAuditWriterSchemaTests
|
||||
Assert.Equal(2, value);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- ExecutionId schema-upgrade regression (persistent auditlog.db) ----- //
|
||||
|
||||
/// <summary>
|
||||
/// The OLD pre-ExecutionId-branch <c>AuditLog</c> schema — the 20-column
|
||||
/// CREATE TABLE WITHOUT the <c>ExecutionId</c> column. A real deployment's
|
||||
/// on-disk <c>auditlog.db</c> already contains exactly this shape, and
|
||||
/// <c>CREATE TABLE IF NOT EXISTS</c> is a no-op against it.
|
||||
/// </summary>
|
||||
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);
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<SqliteAuditWriter>.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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CachedCallTelemetry>();
|
||||
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
|
||||
.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<CachedCallTelemetry>();
|
||||
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
|
||||
.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<CachedCallTelemetry>(t => captured = t), Arg.Any<CancellationToken>())
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
|
||||
/// <para>
|
||||
/// Scenarios covered (per the M7-T16 brief):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>FilterNarrowing</c> — channel chip narrows the results grid.</item>
|
||||
/// <item><c>FilterNarrowing</c> — the channel filter narrows the results grid.</item>
|
||||
/// <item><c>DrilldownDrawer_JsonPrettyPrint</c> — JSON request bodies pretty-print.</item>
|
||||
/// <item><c>CopyAsCurlButton_VisibleOnApiInbound</c> — cURL action visible for API rows.</item>
|
||||
/// <item><c>DrillInFromCorrelationId_AutoLoadsAuditLog</c> — query-string drill-in
|
||||
@@ -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).</item>
|
||||
/// <item><c>DrillInFromExecutionId_LandsOnAuditLogWithFilterContext</c> — the
|
||||
/// <c>?executionId=</c> drill-in (the drawer's "View this execution" action)
|
||||
/// auto-loads the grid filtered by ExecutionId.</item>
|
||||
/// <item><c>NotificationsPage_HasViewAuditHistoryLink_WhenNotificationsExist</c> —
|
||||
/// the report page wires drill-in links when notifications are present.</item>
|
||||
/// <item><c>ExportCsv_LinkIsVisibleAndDownloads</c> — Export CSV button gated on
|
||||
@@ -45,7 +48,7 @@ public class AuditLogPageTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilterNarrowing_ChannelChipShrinksGrid()
|
||||
public async Task FilterNarrowing_ChannelFilterShrinksGrid()
|
||||
{
|
||||
// Skip with a clear message when MSSQL is not reachable — the rest of
|
||||
// the Playwright suite is UI-only and does not need the DB, so this
|
||||
@@ -91,13 +94,13 @@ public class AuditLogPageTests
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Pre-Apply, both rows are absent because the grid stays empty until
|
||||
// the user filters. Click the ApiOutbound chip then Apply.
|
||||
await page.Locator("[data-test='chip-channel-ApiOutbound']").ClickAsync();
|
||||
// the user filters. Pick the ApiOutbound channel, then Apply.
|
||||
await page.Locator("[data-test='filter-channel-select']").SelectOptionAsync("ApiOutbound");
|
||||
await page.Locator("[data-test='filter-apply']").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// The seeded ApiOutbound row is visible; the DbOutbound row is not
|
||||
// (it was filtered out by the channel chip).
|
||||
// (it was filtered out by the channel filter).
|
||||
var apiRow = page.Locator($"[data-test='grid-row-{apiEventId}']");
|
||||
var dbRow = page.Locator($"[data-test='grid-row-{dbEventId}']");
|
||||
await Assertions.Expect(apiRow).ToBeVisibleAsync();
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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<AuditLogPaging>(),
|
||||
@@ -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<AuditLogPaging>(),
|
||||
@@ -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<AuditLogQueryFilter>(f => f.ExecutionId == null),
|
||||
Arg.Any<AuditLogPaging>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only authentication handler that signs every request in as an Admin.
|
||||
/// Admin is in <c>AuditExportRoles</c>, so the endpoint's AuditExport policy
|
||||
|
||||
@@ -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<AuditDrilldownDrawer>(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<AuditDrilldownDrawer>(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<AuditDrilldownDrawer>(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<NavigationManager>();
|
||||
Assert.Contains($"/audit/log?executionId={exec}", nav.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyAsCurl_InvokesClipboard_WithCurlString()
|
||||
{
|
||||
|
||||
@@ -13,13 +13,18 @@ namespace ScadaLink.CentralUI.Tests.Components.Audit;
|
||||
/// <summary>
|
||||
/// bUnit tests for <see cref="AuditFilterBar"/> (#23 M7-T2 / Bundle B).
|
||||
///
|
||||
/// The bar carries the 10 spec filter elements plus the Errors-only toggle. Tests
|
||||
/// pin: (1) the full filter set renders; (2) Apply raises <c>OnFilterChanged</c>
|
||||
/// with collapsed values; (3) the Channel→Kind narrowing map drives Kind chip
|
||||
/// visibility; (4) the Errors-only toggle ORs <c>Failed</c> into Status when
|
||||
/// Status is otherwise empty; (5) the "Last hour" preset populates
|
||||
/// <c>FromUtc</c> to roughly an hour before "now" — proves the time-window
|
||||
/// collapse without freezing the clock.
|
||||
/// The bar carries the 10 spec filter elements plus the Errors-only toggle.
|
||||
/// Channel is a single-select <c><select data-test="filter-channel-select"></c>;
|
||||
/// Kind / Status / Site are
|
||||
/// <see cref="ScadaLink.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
|
||||
/// controls whose options are checkboxes tagged
|
||||
/// <c>data-test="filter-<dim>-ms-opt-<value>"</c>. Tests pin:
|
||||
/// (1) the full filter set renders; (2) Apply raises <c>OnFilterChanged</c> with
|
||||
/// the selected values; (3) the Channel→Kind narrowing map drives Kind option
|
||||
/// visibility; (4) the Errors-only toggle ORs the error statuses into Status when
|
||||
/// Status is otherwise empty; (5) the "Last hour" preset populates <c>FromUtc</c>
|
||||
/// to roughly an hour before "now" — proves the time-window collapse without
|
||||
/// freezing the clock.
|
||||
/// </summary>
|
||||
public class AuditFilterBarTests : BunitContext
|
||||
{
|
||||
@@ -56,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)
|
||||
@@ -71,8 +77,8 @@ public class AuditFilterBarTests : BunitContext
|
||||
var cut = Render<AuditFilterBar>(p => p
|
||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
|
||||
|
||||
// Drive UI: toggle a Channel chip, type in the Target search box, click Apply.
|
||||
cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click();
|
||||
// Drive UI: pick a Channel, type in the Target search box, click Apply.
|
||||
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
|
||||
cut.Find("[data-test=\"filter-target\"] input").Change("Plant-A-OPC");
|
||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||
|
||||
@@ -82,23 +88,25 @@ public class AuditFilterBarTests : BunitContext
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_WithMultipleChannelChips_PassesAllSelectedChannels()
|
||||
public void ChangingChannel_ReplacesTheSelection_SingleSelect()
|
||||
{
|
||||
// Task 9: ToFilter no longer collapses the chip multi-select — every
|
||||
// selected channel chip reaches the filter's Channels list.
|
||||
// Channel is single-select: picking a second channel replaces the first
|
||||
// rather than adding to it (the page filters one channel at a time).
|
||||
AuditLogQueryFilter? captured = null;
|
||||
var cut = Render<AuditFilterBar>(p => p
|
||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
|
||||
|
||||
cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click();
|
||||
cut.Find("[data-test=\"chip-channel-Notification\"]").Click();
|
||||
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
|
||||
cut.Find("[data-test=\"filter-channel-select\"]").Change("Notification");
|
||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.NotNull(captured!.Channels);
|
||||
Assert.Equal(2, captured.Channels!.Count);
|
||||
Assert.Contains(AuditChannel.ApiOutbound, captured.Channels);
|
||||
Assert.Contains(AuditChannel.Notification, captured.Channels);
|
||||
Assert.Equal(new[] { AuditChannel.Notification }, captured!.Channels);
|
||||
|
||||
// Selecting "All channels" clears the channel filter entirely.
|
||||
cut.Find("[data-test=\"filter-channel-select\"]").Change(string.Empty);
|
||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||
Assert.Null(captured!.Channels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -106,23 +114,23 @@ public class AuditFilterBarTests : BunitContext
|
||||
{
|
||||
var cut = Render<AuditFilterBar>();
|
||||
|
||||
// With no Channel selected, every kind chip is in the DOM.
|
||||
// With no Channel selected, every kind option is in the DOM.
|
||||
foreach (var kind in Enum.GetValues<AuditKind>())
|
||||
{
|
||||
Assert.Contains($"data-test=\"chip-kind-{kind}\"", cut.Markup);
|
||||
Assert.Contains($"data-test=\"filter-kind-ms-opt-{kind}\"", cut.Markup);
|
||||
}
|
||||
|
||||
// Select only ApiOutbound; Kind chips outside the channel-kind map drop out.
|
||||
cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click();
|
||||
// Select ApiOutbound; Kind options outside the channel-kind map drop out.
|
||||
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
|
||||
|
||||
var apiKinds = AuditQueryModel.KindsByChannel[AuditChannel.ApiOutbound];
|
||||
foreach (var kind in apiKinds)
|
||||
{
|
||||
Assert.Contains($"data-test=\"chip-kind-{kind}\"", cut.Markup);
|
||||
Assert.Contains($"data-test=\"filter-kind-ms-opt-{kind}\"", cut.Markup);
|
||||
}
|
||||
// Sanity: an unrelated kind is gone.
|
||||
Assert.DoesNotContain($"data-test=\"chip-kind-{AuditKind.NotifySend}\"", cut.Markup);
|
||||
Assert.DoesNotContain($"data-test=\"chip-kind-{AuditKind.InboundRequest}\"", cut.Markup);
|
||||
Assert.DoesNotContain($"data-test=\"filter-kind-ms-opt-{AuditKind.NotifySend}\"", cut.Markup);
|
||||
Assert.DoesNotContain($"data-test=\"filter-kind-ms-opt-{AuditKind.InboundRequest}\"", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -144,8 +152,8 @@ public class AuditFilterBarTests : BunitContext
|
||||
Assert.Contains(AuditStatus.Parked, captured.Statuses);
|
||||
Assert.Contains(AuditStatus.Discarded, captured.Statuses);
|
||||
|
||||
// Now pin an explicit Status chip — Errors-only must yield (chip wins).
|
||||
cut.Find("[data-test=\"chip-status-Delivered\"]").Click();
|
||||
// Now pin an explicit Status option — Errors-only must yield (explicit wins).
|
||||
cut.Find("[data-test=\"filter-status-ms-opt-Delivered\"]").Change(true);
|
||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||
|
||||
Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses);
|
||||
@@ -160,8 +168,8 @@ public class AuditFilterBarTests : BunitContext
|
||||
var cut = Render<AuditFilterBar>(p => p
|
||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
|
||||
|
||||
cut.Find("[data-test=\"chip-status-Delivered\"]").Click();
|
||||
cut.Find("[data-test=\"chip-status-Failed\"]").Click();
|
||||
cut.Find("[data-test=\"filter-status-ms-opt-Delivered\"]").Change(true);
|
||||
cut.Find("[data-test=\"filter-status-ms-opt-Failed\"]").Change(true);
|
||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||
|
||||
Assert.NotNull(captured);
|
||||
@@ -171,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<AuditFilterBar>(p => p
|
||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(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<AuditFilterBar>(p => p
|
||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(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()
|
||||
{
|
||||
|
||||
@@ -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<AuditEvent>
|
||||
{
|
||||
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
|
||||
});
|
||||
|
||||
var cut = Render<AuditResultsGrid>(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<AuditResultsGrid>(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<AuditResultsGrid>(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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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<IAuditLogQueryService>();
|
||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery($"executionId={executionId}", "Admin");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
_queryService.Received().QueryAsync(
|
||||
Arg.Is<AuditLogQueryFilter>(f => f.ExecutionId == executionId),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithUnparseableExecutionIdParam_IsSilentlyDropped_NoAutoLoad()
|
||||
{
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
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<AuditLogQueryFilter>(),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithTargetParam_AppliesTargetFilter()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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<NotificationSubmit>(json);
|
||||
|
||||
Assert.NotNull(roundTripped);
|
||||
Assert.Equal(executionId, roundTripped!.OriginExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using Xunit;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Tests.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ExecutionId Task 5) integration test for the
|
||||
/// <c>AddNotificationOriginExecutionId</c> migration: applies the EF migrations
|
||||
/// to a freshly-created MSSQL test database on the running infra/mssql container
|
||||
/// and asserts that the <c>Notifications</c> table carries the new
|
||||
/// <c>OriginExecutionId</c> column as a nullable <c>uniqueidentifier</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Unlike <c>AuditLog</c>, the <c>Notifications</c> table is not partitioned, so
|
||||
/// the column is a plain metadata-only <c>ALTER TABLE … ADD</c> with no index.
|
||||
/// Tests pair <see cref="SkippableFactAttribute"/> with <c>Skip.IfNot(...)</c> so
|
||||
/// the runner reports them as Skipped (not Passed) when MSSQL is unreachable. The
|
||||
/// fixture applies the migrations once at construction time.
|
||||
/// </remarks>
|
||||
public class AddNotificationOriginExecutionIdMigrationTests : IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
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<int>(
|
||||
"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<string?>(
|
||||
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginExecutionId';");
|
||||
Assert.Equal("uniqueidentifier", dataType);
|
||||
|
||||
var isNullable = await ScalarAsync<string?>(
|
||||
"SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginExecutionId';");
|
||||
Assert.Equal("YES", isNullable);
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
|
||||
private async Task<T> ScalarAsync<T>(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))!;
|
||||
}
|
||||
}
|
||||
@@ -247,6 +247,34 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
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<MsSqlMigrationFixture>
|
||||
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<MsSqlMigrationFixture>
|
||||
Status = status,
|
||||
SourceSiteId = siteId,
|
||||
ErrorMessage = errorMessage,
|
||||
ExecutionId = executionId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -101,15 +101,27 @@ public class DatabaseGatewayTests
|
||||
|
||||
var gateway = new DatabaseGateway(_repository, NullLogger<DatabaseGateway>.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<string, object?> { ["v"] = 1 });
|
||||
new Dictionary<string, object?> { ["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;
|
||||
}
|
||||
|
||||
@@ -371,26 +371,45 @@ public class ExternalSystemClientTests
|
||||
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.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);
|
||||
|
||||
@@ -350,6 +350,51 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Equal(requestJson, evt.RequestSummary);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 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_ExecutionId_And_NullCorrelationId()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.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_ExecutionIds()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var mw = CreateMiddleware(hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(BuildContext());
|
||||
await mw.InvokeAsync(BuildContext());
|
||||
|
||||
Assert.Equal(2, writer.Events.Count);
|
||||
Assert.NotEqual(writer.Events[0].ExecutionId, writer.Events[1].ExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DurationMs_IsRecorded()
|
||||
{
|
||||
|
||||
@@ -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<string, Microsoft.Extensions.Primitives.StringValues>
|
||||
{
|
||||
["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<string, Microsoft.Extensions.Primitives.StringValues>
|
||||
{
|
||||
["executionId"] = "not-a-guid",
|
||||
});
|
||||
|
||||
var filter = AuditEndpoints.ParseFilter(query);
|
||||
|
||||
Assert.Null(filter.ExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_RepeatedChannelParams_ReachRepositoryAsMultiValueFilter()
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -155,13 +157,56 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
|
||||
Assert.Equal("site-alpha", evt.SourceSiteId);
|
||||
Assert.Equal("instance-42", evt.SourceInstanceId);
|
||||
Assert.Equal("AlarmScript", evt.SourceScript);
|
||||
// Central dispatch: actor is null (no authenticated end-user).
|
||||
Assert.Null(evt.Actor);
|
||||
// Central dispatch: Actor is the system identity (no per-call user).
|
||||
Assert.Equal("system", evt.Actor);
|
||||
// Successful attempt: no error message.
|
||||
Assert.Null(evt.ErrorMessage);
|
||||
});
|
||||
}
|
||||
|
||||
[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<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { notification });
|
||||
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
|
||||
var actor = CreateActor([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var attempted = EventsByStatus(AuditStatus.Attempted);
|
||||
Assert.Single(attempted);
|
||||
Assert.Equal(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<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { notification });
|
||||
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
|
||||
var actor = CreateActor([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var attempted = EventsByStatus(AuditStatus.Attempted);
|
||||
Assert.Single(attempted);
|
||||
Assert.Null(attempted[0].ExecutionId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attempt_TransientFailure_EmitsEvent_StatusAttempted_ErrorMessageSet()
|
||||
{
|
||||
|
||||
@@ -39,7 +39,8 @@ public class NotificationOutboxActorIngestTests : TestKit
|
||||
NullLogger<NotificationOutboxActor>.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<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<Notification>(), Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
var executionId = Guid.NewGuid();
|
||||
var submit = MakeSubmit(originExecutionId: executionId);
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(submit, TestActor);
|
||||
|
||||
ExpectMsg<NotificationSubmitAck>();
|
||||
_repository.Received(1).InsertIfNotExistsAsync(
|
||||
Arg.Is<Notification>(n => n.OriginExecutionId == executionId),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_NullOriginExecutionId_PersistsNull()
|
||||
{
|
||||
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
var submit = MakeSubmit(originExecutionId: null);
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(submit, TestActor);
|
||||
|
||||
ExpectMsg<NotificationSubmitAck>();
|
||||
_repository.Received(1).InsertIfNotExistsAsync(
|
||||
Arg.Is<Notification>(n => n.OriginExecutionId == null),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateSubmit_RepositoryReturnsFalse_StillAcksAccepted()
|
||||
{
|
||||
|
||||
@@ -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<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { notification });
|
||||
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
|
||||
var actor = CreateActor([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var delivered = EventsByStatus(AuditStatus.Delivered);
|
||||
Assert.Single(delivered);
|
||||
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<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { notification });
|
||||
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
|
||||
var actor = CreateActor([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var delivered = EventsByStatus(AuditStatus.Delivered);
|
||||
Assert.Single(delivered);
|
||||
Assert.Null(delivered[0].ExecutionId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Terminal_Parked_OnPermanentFailure_EmitsEvent_StatusParked()
|
||||
{
|
||||
@@ -166,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<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { notification });
|
||||
var adapter = new StubAdapter(() => DeliveryOutcome.Permanent("invalid recipient address"));
|
||||
var actor = CreateActor([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var parked = EventsByStatus(AuditStatus.Parked);
|
||||
Assert.Single(parked);
|
||||
Assert.Equal(executionId, parked[0].ExecutionId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Terminal_Parked_OnTransientReachingMaxRetries_EmitsEvent_StatusParked()
|
||||
{
|
||||
|
||||
@@ -39,6 +39,12 @@ public class DatabaseCachedWriteEmissionTests
|
||||
private const string InstanceName = "Plant.Pump42";
|
||||
private const string SourceScript = "ScriptActor:WriteAudit";
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23: a fixed per-execution id so the cached-row tests can
|
||||
/// assert <see cref="AuditEvent.ExecutionId"/> against a known value.
|
||||
/// </summary>
|
||||
private static readonly Guid TestExecutionId = Guid.NewGuid();
|
||||
|
||||
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
|
||||
IDatabaseGateway gateway,
|
||||
ICachedCallTelemetryForwarder? forwarder)
|
||||
@@ -47,6 +53,10 @@ public class DatabaseCachedWriteEmissionTests
|
||||
gateway,
|
||||
InstanceName,
|
||||
NullLogger.Instance,
|
||||
// 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);
|
||||
@@ -62,7 +72,8 @@ public class DatabaseCachedWriteEmissionTests
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||
InstanceName,
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>()))
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
var forwarder = new CapturingForwarder();
|
||||
|
||||
@@ -76,7 +87,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);
|
||||
@@ -97,7 +111,8 @@ public class DatabaseCachedWriteEmissionTests
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>()))
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
var forwarder = new CapturingForwarder();
|
||||
|
||||
@@ -121,7 +136,8 @@ public class DatabaseCachedWriteEmissionTests
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>()))
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
var forwarder = new CapturingForwarder();
|
||||
|
||||
@@ -134,7 +150,48 @@ public class DatabaseCachedWriteEmissionTests
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||
InstanceName,
|
||||
It.IsAny<CancellationToken>(),
|
||||
trackedId),
|
||||
trackedId,
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ExecutionId Task 4): the helper → gateway hop of the
|
||||
/// threading chain. The cached-write helper must forward the runtime
|
||||
/// context's <c>ExecutionId</c> and <c>SourceScript</c> verbatim into
|
||||
/// <see cref="IDatabaseGateway.CachedWriteAsync"/> — so the buffered retry
|
||||
/// loop later stamps the right provenance onto its audit rows. This
|
||||
/// asserts the exact id/script (not <c>It.IsAny</c>), so a regression that
|
||||
/// dropped the threading would fail here.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CachedWrite_ThreadsExecutionIdAndSourceScript_IntoGateway()
|
||||
{
|
||||
var gateway = new Mock<IDatabaseGateway>();
|
||||
gateway
|
||||
.Setup(g => g.CachedWriteAsync(
|
||||
"myDb", "INSERT INTO t VALUES (1)",
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||
InstanceName,
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
||||
.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<IReadOnlyDictionary<string, object?>?>(),
|
||||
InstanceName,
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.Is<Guid?>(id => id == TestExecutionId),
|
||||
It.Is<string?>(s => s == SourceScript)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
@@ -148,7 +205,8 @@ public class DatabaseCachedWriteEmissionTests
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>()))
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
var forwarder = new CapturingForwarder
|
||||
{
|
||||
@@ -164,7 +222,8 @@ public class DatabaseCachedWriteEmissionTests
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||
InstanceName,
|
||||
It.IsAny<CancellationToken>(),
|
||||
trackedId),
|
||||
trackedId,
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>()),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,14 +48,28 @@ public class DatabaseSyncEmissionTests
|
||||
private const string SourceScript = "ScriptActor:Sync";
|
||||
private const string ConnectionName = "machineData";
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23: a fixed per-execution id used by the default
|
||||
/// <see cref="CreateHelper(IDatabaseGateway, IAuditWriter?)"/>
|
||||
/// overload so assertions can compare against a known value.
|
||||
/// </summary>
|
||||
private static readonly Guid TestExecutionId = Guid.NewGuid();
|
||||
|
||||
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
|
||||
IDatabaseGateway gateway,
|
||||
IAuditWriter? auditWriter)
|
||||
=> CreateHelper(gateway, auditWriter, TestExecutionId);
|
||||
|
||||
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
|
||||
IDatabaseGateway gateway,
|
||||
IAuditWriter? auditWriter,
|
||||
Guid executionId)
|
||||
{
|
||||
return new ScriptRuntimeContext.DatabaseHelper(
|
||||
gateway,
|
||||
InstanceName,
|
||||
NullLogger.Instance,
|
||||
executionId,
|
||||
auditWriter: auditWriter,
|
||||
siteId: SiteId,
|
||||
sourceScript: SourceScript,
|
||||
@@ -266,11 +280,40 @@ public class DatabaseSyncEmissionTests
|
||||
Assert.Equal(SiteId, evt.SourceSiteId);
|
||||
Assert.Equal(InstanceName, evt.SourceInstanceId);
|
||||
Assert.Equal(SourceScript, evt.SourceScript);
|
||||
Assert.Null(evt.Actor);
|
||||
// Outbound channel: Actor carries the calling script identity.
|
||||
Assert.Equal(SourceScript, evt.Actor);
|
||||
// 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_StampsExecutionId_AndNullCorrelationId()
|
||||
{
|
||||
using var keepAlive = new SqliteConnection("Data Source=kc;Mode=Memory;Cache=Shared");
|
||||
var inner = NewInMemoryDb(out var _);
|
||||
var gateway = new Mock<IDatabaseGateway>();
|
||||
gateway
|
||||
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(inner);
|
||||
var writer = new CapturingAuditWriter();
|
||||
var executionId = Guid.NewGuid();
|
||||
|
||||
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(executionId, evt.ExecutionId);
|
||||
// Sync one-shot call: CorrelationId is null (no operation lifecycle).
|
||||
Assert.Null(evt.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DurationMs_NonZero()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
using Akka.Actor;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.SiteRuntime.Scripts;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 — execution-correlation tests exercised through a full
|
||||
/// <see cref="ScriptRuntimeContext"/>:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// The <c>?? Guid.NewGuid()</c> fallback in the <see cref="ScriptRuntimeContext"/>
|
||||
/// 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.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// The execution-wide contract: an <c>ExternalSystem.Call</c> and a sync
|
||||
/// <c>Database</c> write performed through ONE context share a single
|
||||
/// <see cref="AuditEvent.ExecutionId"/>. The per-operation
|
||||
/// <see cref="AuditEvent.CorrelationId"/> stays null for these sync one-shot
|
||||
/// calls — a sync call has no operation lifecycle.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class ExecutionCorrelationContextTests
|
||||
{
|
||||
/// <summary>
|
||||
/// In-memory <see cref="IAuditWriter"/> capturing every emitted event
|
||||
/// (mirrors the <c>CapturingAuditWriter</c> stubs in
|
||||
/// <see cref="ExternalSystemCallAuditEmissionTests"/> /
|
||||
/// <see cref="DatabaseSyncEmissionTests"/>).
|
||||
/// </summary>
|
||||
private sealed class CapturingAuditWriter : IAuditWriter
|
||||
{
|
||||
public List<AuditEvent> Events { get; } = new();
|
||||
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
Events.Add(evt);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private const string InstanceName = "Plant.Pump42";
|
||||
private const string ConnectionName = "machineData";
|
||||
|
||||
/// <summary>
|
||||
/// Builds a full <see cref="ScriptRuntimeContext"/> wired with the external
|
||||
/// system client, database gateway and audit writer the cross-helper test
|
||||
/// needs. The actor refs are <see cref="ActorRefs.Nobody"/> — the
|
||||
/// integration helpers (ExternalSystem / Database) never touch them — and
|
||||
/// <paramref name="executionId"/> defaults to null so the ctor's
|
||||
/// <c>?? Guid.NewGuid()</c> fallback is exercised unless a test supplies one.
|
||||
/// </summary>
|
||||
private static ScriptRuntimeContext CreateContext(
|
||||
IExternalSystemClient? externalSystemClient,
|
||||
IDatabaseGateway? databaseGateway,
|
||||
IAuditWriter? auditWriter,
|
||||
Guid? executionId = null)
|
||||
{
|
||||
var compilationService = new ScriptCompilationService(
|
||||
NullLogger<ScriptCompilationService>.Instance);
|
||||
var sharedScriptLibrary = new SharedScriptLibrary(
|
||||
compilationService, NullLogger<SharedScriptLibrary>.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: "site-77",
|
||||
sourceScript: "ScriptActor:OnTick",
|
||||
auditWriter: auditWriter,
|
||||
operationTrackingStore: null,
|
||||
cachedForwarder: null,
|
||||
executionId: executionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spin up a fresh in-memory SQLite database with a tiny single-table
|
||||
/// schema. The keep-alive root must outlive any auditing wrapper the test
|
||||
/// exercises (mirrors <c>DatabaseSyncEmissionTests.NewInMemoryDb</c>).
|
||||
/// </summary>
|
||||
private static SqliteConnection NewInMemoryDb(out SqliteConnection keepAlive)
|
||||
{
|
||||
var dbName = $"db-{Guid.NewGuid():N}";
|
||||
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
|
||||
|
||||
keepAlive = new SqliteConnection(connStr);
|
||||
keepAlive.Open();
|
||||
using (var seed = keepAlive.CreateCommand())
|
||||
{
|
||||
seed.CommandText =
|
||||
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);";
|
||||
seed.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
var live = new SqliteConnection(connStr);
|
||||
live.Open();
|
||||
return live;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoExecutionIdSupplied_SyncCall_StampsFreshNonEmptyExecutionId()
|
||||
{
|
||||
// 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<IExternalSystemClient>();
|
||||
client
|
||||
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
|
||||
var writer = new CapturingAuditWriter();
|
||||
|
||||
var context = CreateContext(client.Object, databaseGateway: null, writer);
|
||||
await context.ExternalSystem.Call("ERP", "GetOrder");
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
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_ShareTheSameExecutionId()
|
||||
{
|
||||
// The execution-wide contract: an ExternalSystem.Call AND a sync
|
||||
// Database write performed through ONE ScriptRuntimeContext must both
|
||||
// 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 _);
|
||||
|
||||
var client = new Mock<IExternalSystemClient>();
|
||||
client
|
||||
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
|
||||
|
||||
var gateway = new Mock<IDatabaseGateway>();
|
||||
gateway
|
||||
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(innerDb);
|
||||
|
||||
var writer = new CapturingAuditWriter();
|
||||
var context = CreateContext(client.Object, gateway.Object, writer);
|
||||
|
||||
// 1) outbound API call through the context's ExternalSystem helper.
|
||||
await context.ExternalSystem.Call("ERP", "GetOrder");
|
||||
|
||||
// 2) sync DB write through the SAME context's Database helper.
|
||||
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')";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
Assert.Equal(2, writer.Events.Count);
|
||||
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.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 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);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,12 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
private const string InstanceName = "Plant.Pump42";
|
||||
private const string SourceScript = "ScriptActor:CheckPressure";
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23: a fixed per-execution id so the cached-row tests can
|
||||
/// assert <see cref="AuditEvent.ExecutionId"/> against a known value.
|
||||
/// </summary>
|
||||
private static readonly Guid TestExecutionId = Guid.NewGuid();
|
||||
|
||||
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
|
||||
IExternalSystemClient client,
|
||||
ICachedCallTelemetryForwarder? forwarder)
|
||||
@@ -49,6 +55,10 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
client,
|
||||
InstanceName,
|
||||
NullLogger.Instance,
|
||||
// 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,
|
||||
@@ -65,7 +75,8 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||
InstanceName,
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>()))
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
|
||||
var forwarder = new CapturingForwarder();
|
||||
|
||||
@@ -80,7 +91,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.
|
||||
@@ -94,6 +108,43 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
Assert.Null(packet.Operational.TerminalAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CachedCall_ImmediateCompletion_CapturesRequestArgs_AndResponseBody()
|
||||
{
|
||||
var client = new Mock<IExternalSystemClient>();
|
||||
client
|
||||
.Setup(c => c.CachedCallAsync(
|
||||
"ERP", "GetOrder",
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||
InstanceName,
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
|
||||
var forwarder = new CapturingForwarder();
|
||||
|
||||
var helper = CreateHelper(client.Object, forwarder);
|
||||
var args = new Dictionary<string, object?> { ["orderId"] = 42 };
|
||||
await helper.CachedCall("ERP", "GetOrder", args);
|
||||
|
||||
// Immediate completion (WasBuffered=false) emits Submit, Attempted, Resolve.
|
||||
Assert.Equal(3, forwarder.Telemetry.Count);
|
||||
var submit = forwarder.Telemetry.Single(t => t.Audit.Kind == AuditKind.CachedSubmit);
|
||||
var attempted = forwarder.Telemetry.Single(t => t.Audit.Kind == AuditKind.ApiCallCached);
|
||||
var resolve = forwarder.Telemetry.Single(t => t.Audit.Kind == AuditKind.CachedResolve);
|
||||
|
||||
// Every row carries the request args; the two post-call rows also carry
|
||||
// the response body (Submit precedes the call, so it has no response).
|
||||
Assert.Equal("{\"orderId\":42}", submit.Audit.RequestSummary);
|
||||
Assert.Null(submit.Audit.ResponseSummary);
|
||||
|
||||
Assert.Equal("{\"orderId\":42}", attempted.Audit.RequestSummary);
|
||||
Assert.Equal("{\"ok\":true}", attempted.Audit.ResponseSummary);
|
||||
|
||||
Assert.Equal("{\"orderId\":42}", resolve.Audit.RequestSummary);
|
||||
Assert.Equal("{\"ok\":true}", resolve.Audit.ResponseSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CachedCall_ReturnsTrackedOperationId()
|
||||
{
|
||||
@@ -104,7 +155,8 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>()))
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
|
||||
var forwarder = new CapturingForwarder();
|
||||
|
||||
@@ -123,14 +175,56 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||
InstanceName,
|
||||
It.IsAny<CancellationToken>(),
|
||||
id1),
|
||||
id1,
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>()),
|
||||
Times.Once);
|
||||
client.Verify(c => c.CachedCallAsync(
|
||||
"ERP", "GetOrder",
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||
InstanceName,
|
||||
It.IsAny<CancellationToken>(),
|
||||
id2),
|
||||
id2,
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ExecutionId Task 4): the helper → gateway hop of the
|
||||
/// threading chain. The cached-call helper must forward the runtime
|
||||
/// context's <c>ExecutionId</c> and <c>SourceScript</c> verbatim into
|
||||
/// <see cref="IExternalSystemClient.CachedCallAsync"/> — so the buffered
|
||||
/// retry loop later stamps the right provenance onto its audit rows.
|
||||
/// This asserts the exact id/script (not <c>It.IsAny</c>), so a regression
|
||||
/// that dropped the threading would fail here.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CachedCall_ThreadsExecutionIdAndSourceScript_IntoClient()
|
||||
{
|
||||
var client = new Mock<IExternalSystemClient>();
|
||||
client
|
||||
.Setup(c => c.CachedCallAsync(
|
||||
"ERP", "GetOrder",
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||
InstanceName,
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
||||
.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<IReadOnlyDictionary<string, object?>?>(),
|
||||
InstanceName,
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.Is<Guid?>(id => id == TestExecutionId),
|
||||
It.Is<string?>(s => s == SourceScript)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
@@ -144,7 +238,8 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>()))
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
|
||||
var forwarder = new CapturingForwarder
|
||||
{
|
||||
@@ -163,7 +258,8 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||
InstanceName,
|
||||
It.IsAny<CancellationToken>(),
|
||||
trackedId),
|
||||
trackedId,
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
@@ -177,7 +273,8 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>()))
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
|
||||
var forwarder = new CapturingForwarder();
|
||||
|
||||
@@ -203,7 +300,8 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>()))
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
|
||||
|
||||
var helper = CreateHelper(client.Object, forwarder: null);
|
||||
@@ -215,7 +313,8 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||
InstanceName,
|
||||
It.IsAny<CancellationToken>(),
|
||||
trackedId),
|
||||
trackedId,
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
@@ -244,7 +343,8 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||
InstanceName,
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>()))
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
||||
// WasBuffered=false — the immediate HTTP attempt succeeded; S&F
|
||||
// is bypassed entirely.
|
||||
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
|
||||
@@ -259,6 +359,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);
|
||||
|
||||
@@ -266,7 +367,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);
|
||||
@@ -277,6 +381,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.
|
||||
@@ -300,7 +405,8 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||
InstanceName,
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>()))
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new ExternalCallResult(
|
||||
false, null, "Permanent error: HTTP 422 bad payload", WasBuffered: false));
|
||||
var forwarder = new CapturingForwarder();
|
||||
@@ -342,7 +448,8 @@ public class ExternalSystemCachedCallEmissionTests
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||
InstanceName,
|
||||
It.IsAny<CancellationToken>(),
|
||||
It.IsAny<TrackedOperationId?>()))
|
||||
It.IsAny<TrackedOperationId?>(),
|
||||
It.IsAny<Guid?>(), It.IsAny<string?>()))
|
||||
// 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));
|
||||
|
||||
@@ -45,14 +45,28 @@ public class ExternalSystemCallAuditEmissionTests
|
||||
private const string InstanceName = "Plant.Pump42";
|
||||
private const string SourceScript = "ScriptActor:CheckPressure";
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23: a fixed per-execution id used by the default
|
||||
/// <see cref="CreateHelper(IExternalSystemClient, IAuditWriter?)"/>
|
||||
/// overload so assertions can compare against a known value.
|
||||
/// </summary>
|
||||
private static readonly Guid TestExecutionId = Guid.NewGuid();
|
||||
|
||||
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
|
||||
IExternalSystemClient client,
|
||||
IAuditWriter? auditWriter)
|
||||
=> CreateHelper(client, auditWriter, TestExecutionId);
|
||||
|
||||
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
|
||||
IExternalSystemClient client,
|
||||
IAuditWriter? auditWriter,
|
||||
Guid executionId)
|
||||
{
|
||||
return new ScriptRuntimeContext.ExternalSystemHelper(
|
||||
client,
|
||||
InstanceName,
|
||||
NullLogger.Instance,
|
||||
executionId,
|
||||
auditWriter,
|
||||
SiteId,
|
||||
SourceScript);
|
||||
@@ -81,6 +95,29 @@ public class ExternalSystemCallAuditEmissionTests
|
||||
Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind);
|
||||
Assert.NotEqual(Guid.Empty, evt.EventId);
|
||||
Assert.False(evt.PayloadTruncated);
|
||||
// No call arguments → null request summary; the response body is captured.
|
||||
Assert.Null(evt.RequestSummary);
|
||||
Assert.Equal("{}", evt.ResponseSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Call_CapturesRequestArgs_AndResponseBody_OnTheAuditRow()
|
||||
{
|
||||
var client = new Mock<IExternalSystemClient>();
|
||||
client
|
||||
.Setup(c => c.CallAsync("Weather", "GetCurrent", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ExternalCallResult(true, "{\"tempC\":11.4}", null));
|
||||
var writer = new CapturingAuditWriter();
|
||||
|
||||
var helper = CreateHelper(client.Object, writer);
|
||||
var args = new Dictionary<string, object?> { ["city"] = "Dublin" };
|
||||
await helper.Call("Weather", "GetCurrent", args);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
// RequestSummary is the serialized argument dictionary; ResponseSummary
|
||||
// is the verbatim response body. (Cap + redaction are the writer's job.)
|
||||
Assert.Equal("{\"city\":\"Dublin\"}", evt.RequestSummary);
|
||||
Assert.Equal("{\"tempC\":11.4}", evt.ResponseSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -186,10 +223,58 @@ public class ExternalSystemCallAuditEmissionTests
|
||||
Assert.Equal(SiteId, evt.SourceSiteId);
|
||||
Assert.Equal(InstanceName, evt.SourceInstanceId);
|
||||
Assert.Equal(SourceScript, evt.SourceScript);
|
||||
Assert.Null(evt.Actor);
|
||||
// Outbound channel: Actor carries the calling script identity.
|
||||
Assert.Equal(SourceScript, evt.Actor);
|
||||
// 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_StampsExecutionId_AndNullCorrelationId()
|
||||
{
|
||||
var client = new Mock<IExternalSystemClient>();
|
||||
client
|
||||
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
|
||||
var writer = new CapturingAuditWriter();
|
||||
var executionId = Guid.NewGuid();
|
||||
|
||||
var helper = CreateHelper(client.Object, writer, executionId);
|
||||
await helper.Call("ERP", "GetOrder");
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
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_ShareTheSameExecutionId()
|
||||
{
|
||||
var client = new Mock<IExternalSystemClient>();
|
||||
client
|
||||
.Setup(c => c.CallAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
|
||||
var writer = new CapturingAuditWriter();
|
||||
var executionId = Guid.NewGuid();
|
||||
|
||||
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 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]
|
||||
public async Task DurationMs_Recorded_NonZero()
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
@@ -69,7 +70,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
|
||||
"Plant.Pump3",
|
||||
sourceScript,
|
||||
TimeSpan.FromSeconds(3),
|
||||
NullLogger.Instance);
|
||||
NullLogger.Instance,
|
||||
executionId ?? Guid.NewGuid());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -133,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<NotificationSubmit>(buffered!.PayloadJson);
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(executionId, payload!.OriginExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_WhenHelperHasNoSourceScript_LeavesSourceScriptNull()
|
||||
{
|
||||
|
||||
@@ -53,6 +53,12 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
|
||||
private const string Subject = "Pump alarm";
|
||||
private const string Body = "Pump 3 tripped";
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23: a fixed per-execution id so the NotifySend test can
|
||||
/// assert <see cref="AuditEvent.ExecutionId"/> against a known value.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -127,7 +134,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Null(evt.HttpStatus);
|
||||
Assert.Null(evt.ErrorMessage);
|
||||
Assert.Null(evt.ErrorDetail);
|
||||
Assert.Null(evt.Actor);
|
||||
// Outbound channel: Actor carries the calling script identity.
|
||||
Assert.Equal(SourceScript, evt.Actor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -199,7 +207,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Equal(SiteId, evt.SourceSiteId);
|
||||
Assert.Equal(InstanceName, evt.SourceInstanceId);
|
||||
Assert.Equal(SourceScript, evt.SourceScript);
|
||||
Assert.Null(evt.Actor);
|
||||
// Outbound channel: Actor carries the calling script identity.
|
||||
Assert.Equal(SourceScript, evt.Actor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -212,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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<NotificationSubmit>();
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -293,6 +293,165 @@ 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 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()
|
||||
{
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user