test(coverage): close Theme 8 — 13 test-coverage findings, +35 tests

13 well-bounded test-coverage gaps closed across 11 test projects.
Net +35 regression tests; no production code changes except the
SiteEventLogger src reference unchanged (W3 redacted only test code).

Test additions:
- CLI-022: CommandTreeTests pinned-count assertion bumped 14→16 and
  3 InlineData rows added for the audit + bundle command groups.
- Commons-020: new TransportRecordsTests covers BundleManifest /
  ExportSelection / ImportPreview / ImportResolution / ImportResult —
  ctor + System.Text.Json round-trip + record-equality (14 tests).
- CD-024: SPLIT-RANGE failure-continuation now under
  EnsureLookahead_SecondSplitThrows_LoopAborts_FirstBoundaryStillCommitted
  (Skippable MS-SQL fixture); production-shape rowversion delete
  asserted by DeleteDeploymentRecord_CurrentRowVersion_StubAttachPath_DeleteSucceeds.
- CentralUI-033: new QueryStringDrillInTests with 4 bUnit cases for
  Transport + SiteCalls drill-in / query-string handling.
- DM-024: probe actors (ReconcileProbeActor, SerializationProbeActor,
  ArtifactProbeActor) refactored from static fields to per-test instances
  (Interlocked on counter) — all 31 callers updated; no production
  changes required.
- HM-022: real-time PeriodicTimer test flake fixed by replacing
  fixed-budget Task.Delay with a RunLoopUntil poll-until-condition
  helper (5s/25ms). Production loop untouched.
- InboundAPI-023: new EndpointExtensionsTests covers the
  POST /api/{methodName} composition wiring via TestServer (7 cases:
  happy path, missing key 401, unknown method 403, invalid JSON 400,
  missing param 400, script-throws 500 sanitised, AuditActorItemKey
  stash invariant).
- MgmtSvc-021: 6 new ManagementActorTests cover the Transport bundle
  handlers (role gate for Export/Preview/Import, unknown-name
  ManagementCommandException, blocker-rejection, dedupe last-write-wins).
- SCA-006: SiteCallQueryRequest_StuckOnly_CursorAtNonStuckBoundary_SkipsToNextStuckRow
  pins the missing boundary case.
- SEL-023: stress-test `bool stop` promoted to `volatile bool` for
  cross-thread visibility under release/JIT.

Verify-only resolutions:
- NS-024: closed by NS-019 (commit ac96b83 deletion of
  NotificationDeliveryService + its test file). No edits needed.
- NotifOutbox-008: FallbackMaxRetries/FallbackRetryDelay are private
  forward-compat constants returned only when no SMTP-config row exists
  (in which case EmailNotificationDeliveryAdapter returns Permanent,
  bypassing the values entirely). Marked Resolved with note.
- Transport-010: Overwrite child-collection sync covered by the T-001/
  T-002 tests added in commit e3ca9af; per-IP throttle by
  BundleUnlockRateLimiterTests; failed-session retention by
  BundleSessionStoreTests; T-009 closed structurally via AsyncLocal.
  Marked Resolved by reference.

Build clean; all 11 affected test suites green. README regenerated:
33 open (was 46).
This commit is contained in:
Joseph Doherty
2026-05-28 08:21:03 -04:00
parent 46cb6965ac
commit d190345ef0
26 changed files with 1725 additions and 155 deletions
+4 -6
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 3 |
| Open findings | 2 |
## Summary
@@ -1016,9 +1016,11 @@ _Unresolved._
|--|--|
| Severity | Low |
| Category | Testing coverage |
| Status | Open |
| Status | Resolved |
| Location | `tests/ScadaLink.CLI.Tests/CommandTreeTests.cs:21-37`, `:55-58` (vs. `src/ScadaLink.CLI/Program.cs:21-36`) |
**Resolution (2026-05-28):** Added `AuditCommands.Build` and `BundleCommands.Build` to `AllCommandGroups()`, bumped the count assertion to `Equal(16, …)` with a maintenance comment, and added three new sub-command-surface tests (`AllCommandGroups_Contains_AuditAndBundle`, `AuditCommandGroup_HasQueryExportAndVerifyChain`, `BundleCommandGroup_HasExportPreviewAndImport`). `CommandPayloadTypes_ResolveViaRegistry` now also pins `ExportBundleCommand` / `PreviewBundleCommand` / `ImportBundleCommand` through `ManagementCommandRegistry`.
**Description**
`CommandTreeTests.AllCommandGroups()` builds 14 command groups; `Program.cs` now
@@ -1040,10 +1042,6 @@ representative payload types to `CommandPayloadTypes_ResolveViaRegistry`
add a `BundleCommandsTests` file covering the success-envelope parse and the
`NameListOption` comma-split parser.
**Resolution**
_Unresolved._
### CLI-023 — `Component-CLI.md` claims audit commands ride `POST /management`; implementation uses REST endpoints
| | |
+4 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 3 |
| Open findings | 2 |
## Summary
@@ -1563,9 +1563,11 @@ forward-only paging on the Audit Log grid.
|--|--|
| Severity | Low |
| Category | Testing coverage |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor.cs:97-238,267-319`; `src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs:107-148`; `tests/ScadaLink.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs`; `tests/ScadaLink.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs` |
**Resolution (2026-05-28):** Added `tests/ScadaLink.CentralUI.Tests/Pages/QueryStringDrillInTests.cs` (4 bUnit tests). For `SiteCallsReport` it pins the case-insensitive `?status=parked` → canonical "Parked" normalisation, the unrecognised-status silent drop, and the non-boolean `?stuck=yes` silent drop — gaps the existing `SiteCallsReportPageTests` (which covered the Parked / stuck=true / no-params happy paths) did not exercise. For `TransportImport` it asserts that the wizard has no `[Parameter]`-bound query keys: an unrecognised drill-in URL (`?bundleImportId=…&foo=bar`) leaves `_step` at `Upload` and the Step-1 InputFile control renders cleanly.
**Description**
The CentralUI-025 lesson — "a critical drill-in/redirect path was untested, so the
+4 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 6 |
| Open findings | 5 |
## Summary
@@ -969,9 +969,11 @@ should be documented in REQ-COM-1 as a deliberate choice.
|--|--|
| Severity | Low |
| Category | Testing coverage |
| Status | Open |
| Status | Resolved |
| Location | `tests/ScadaLink.Commons.Tests/` |
**Resolution (2026-05-28):** Added `tests/ScadaLink.Commons.Tests/Types/Transport/TransportRecordsTests.cs` (14 tests) covering ctor and System.Text.Json round-trip for `BundleManifest`, `ExportSelection`, `ImportPreview` + `ImportPreviewItem`, `ImportResolution` (all four `ResolutionAction`s), and `ImportResult`, plus a record-equality sanity check that catches a positional/tuple slip. `EncryptionMetadata` (Commons-015), `AuditEvent` (init-setter / `SourceNode`), `CachedCallTelemetry`, and `AuditTelemetryEnvelope` were verified already covered by their existing focused test files; no new tests were added for those to avoid duplication.
**Description**
The Transport (#24) work adds nine records under `Types/Transport/` (`BundleManifest`,
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 1 |
| Open findings | 0 |
## Summary
@@ -1340,9 +1340,11 @@ index name remains `IX_AuditLog_CorrelationId`.
|--|--|
| Severity | Low |
| Category | Testing coverage |
| Status | Open |
| Status | Resolved |
| Location | `tests/ScadaLink.ConfigurationDatabase.Tests/Maintenance/AuditLogPartitionMaintenanceTests.cs`, `tests/.../RepositoryCoverageTests.cs:855-869` |
**Resolution (2026-05-28):** (1) Added `AuditLogPartitionMaintenanceTests.EnsureLookahead_SecondSplitThrows_LoopAborts_FirstBoundaryStillCommitted` (Skippable, MS SQL fixture) — installs a `DbCommandInterceptor` that lets the 1st `ALTER PARTITION FUNCTION pf_AuditLog_Month() SPLIT RANGE` through and throws on the 2nd, asserts the exception propagates (CD-019's no-try/catch behaviour), counts exactly one successful split, and verifies the first boundary IS now persisted in `pf_AuditLog_Month` so the next tick resumes from N+1 with no holes. (2) Added `DeploymentManagerRepositoryTests.DeleteDeploymentRecord_CurrentRowVersion_StubAttachPath_DeleteSucceeds` — production-shape happy path: caller holds the current RowVersion, change-tracker cleared, delete completes without throwing `DbUpdateConcurrencyException` and the row is gone (1 row affected).
**Description**
`AuditLogPartitionMaintenanceTests` exercises the happy-path SPLIT-RANGE behaviour
+4 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 3 |
| Open findings | 2 |
## Summary
@@ -1202,9 +1202,11 @@ N-site deployment.
|--|--|
| Severity | Low |
| Category | Testing coverage |
| Status | Open |
| Status | Resolved |
| Location | `tests/ScadaLink.DeploymentManager.Tests/DeploymentServiceTests.cs:966-1075`, `tests/ScadaLink.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs:196-217` |
**Resolution (2026-05-28):** Replaced the `static` counters with per-test instance state. Introduced `ReconcileProbeCounters` and `SerializationProbeCounters` (in `DeploymentServiceTests`) and `ArtifactProbeRecorder` (in `ArtifactDeploymentServiceTests`); each probe actor now takes the counter object as its first constructor argument. Every test instantiates a fresh counter local, passes it via `Props.Create(() => new ReconcileProbeActor(counters, ...))`, and reads the counts directly off `counters` — no shared static fields remain. `ReconcileProbeActor`'s counter increments swap to `Interlocked.Increment` for the cross-thread CAS, and `SerializationProbeActor` retains its lock on a per-test `Gate`. All 85 `ScadaLink.DeploymentManager.Tests` continue to pass after the refactor.
**Description**
`ReconcileProbeActor.QueryCount` / `DeployCount`, `SerializationProbeActor.MaxConcurrent`
+4 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 4 |
| Open findings | 3 |
## Summary
@@ -1085,9 +1085,11 @@ preserved.
|--|--|
| Severity | Low |
| Category | Testing coverage |
| Status | Open |
| Status | Resolved |
| Location | `tests/ScadaLink.HealthMonitoring.Tests/CentralHealthReportLoopTests.cs:32-42` |
**Resolution (2026-05-28):** Picked the less-invasive recommended option (a) — kept the real-time `PeriodicTimer` but replaced the fixed-budget `Task.Delay` with a generous poll-until-condition helper. `RunLoopUntil(loop, condition, maxWait = 5s)` starts the hosted service, polls `condition` every 25 ms with a 5 s outer cap, and stops cleanly when met; `GeneratesCentralReports_WhenSelfIsPrimary`, `AssignsMonotonicSequenceNumbers`, and `ProcessReportFailure_PreservesIntervalCountersForNextReport` now use it. The legacy `RunLoopBriefly` retains a fixed wait (≥ 1 s) for the two tests that assert *absence* of reports (`GeneratesNoReports_WhenNotPrimary`, `SetsActiveNodeFlag_EvenWhenNotPrimary`) since there is no condition to poll for. Refactoring the production loop to consume `TimeProvider.CreateTimer` (option b) was rejected for batch scope — it would require a production change for what is currently a low-severity test-hygiene gap. All 73 `ScadaLink.HealthMonitoring.Tests` pass; the new generous budget tolerates slow CI runners.
**Description**
`RunLoopBriefly` starts the hosted service with a 50 ms `PeriodicTimer` and
+4 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 1 |
| Open findings | 0 |
## Summary
@@ -1152,9 +1152,11 @@ realisation of the InboundAPI-008 vulnerability.
|--|--|
| Severity | Low |
| Category | Testing coverage |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/EndpointExtensions.cs:31-140`, `tests/ScadaLink.InboundAPI.Tests/` |
**Resolution (2026-05-28):** Added `tests/ScadaLink.InboundAPI.Tests/EndpointExtensionsTests.cs`, a `TestServer`-hosted suite (same pattern as `EndpointContentTypeTests`) that drives the `POST /api/{methodName}` wiring end-to-end. Seven cases pin the composed flow: happy path (200 + script result body), missing API key (401), unknown method (403, indistinguishable from "not approved" per InboundAPI-011), invalid JSON body (400), missing required parameter (400 from `ParameterValidator`), script throws (500 with sanitized error body — the executor's catch-all replaces the raw exception with `"Internal script error"`), and the `HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey]` actor-stash invariant (verified by an inline capture middleware reading the slot after the endpoint runs). All 7 new tests pass; total InboundAPI.Tests now 158 (was 151).
**Description**
The endpoint handler `HandleInboundApiRequest` is the wiring composition that
+4 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 1 (1 Deferred — see ManagementService-012) |
| Open findings | 0 (1 Deferred — see ManagementService-012) |
## Summary
@@ -988,9 +988,11 @@ Add regression tests: `UpdateSmtpConfig_DoesNotEchoCredentialsInResponse` and
|--|--|
| Severity | Medium |
| Category | Testing coverage |
| Status | Open |
| Status | Resolved |
| Location | `tests/ScadaLink.ManagementService.Tests/ManagementActorTests.cs:1`; `src/ScadaLink.ManagementService/ManagementActor.cs:1717``:1897` |
**Resolution (2026-05-28):** Added six `ManagementActorTests` cases covering the load-bearing bundle behaviours. Role gating: `ExportBundleCommand_WithAdminRole_ReturnsUnauthorized` (Export needs Design), `PreviewBundleCommand_WithDesignRole_ReturnsUnauthorized`, and `ImportBundleCommand_WithDesignRole_ReturnsUnauthorized` (Preview/Import need Admin). Name resolution: `ExportBundleCommand_WithUnknownTemplateName_ReturnsManagementError` proves the `ResolveIds` `ManagementCommandException` surfaces verbatim with the missing entity type + name. Handler logic: `ImportBundleCommand_WithBlockerRow_AbortsBeforeApply` seeds a `ConflictKind.Blocker` preview and asserts `IBundleImporter.ApplyAsync` is never called and the error names the blocker; `ImportBundleCommand_DuplicatePreviewItems_DedupePerEntityTypeAndName` seeds three rows for the same `(Template, "Dup")` key (Identical, Modified, Identical) and asserts only one resolution reaches `ApplyAsync` with last-write-wins (`Skip`, overriding the prior `Modified`/`Overwrite`). All bundle tests use substituted `IBundleExporter`/`IBundleImporter` via a new `AddBundleSubstitutes()` helper plus stub `GetAll*Async` repository returns. 106/106 ManagementService.Tests pass.
**Description**
The three Transport (#24) bundle handlers — `HandleExportBundle`, `HandlePreviewBundle`,
+4 -6
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 8 |
| Open findings | 7 |
## Summary
@@ -362,9 +362,11 @@ remains tracked separately.
|--|--|
| Severity | Low |
| Category | Testing coverage |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs:29-31`, `:251-259`; tests in `tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorDispatchTests.cs` |
**Resolution (2026-05-28):** The `FallbackMaxRetries` / `FallbackRetryDelay` constants are documented for forward-compat with a deferred secondary-adapter path — when no SMTP configuration row exists, `EmailNotificationDeliveryAdapter` returns `Permanent("No SMTP configuration available")` before the retry-policy values are ever consulted, so the path is effectively unreachable from any current production caller. The reachable use of the constants — clamping a non-positive `SmtpConfiguration.MaxRetries` / `RetryDelay` (per NO-002) — is already covered by `TransientFailure_WithZeroMaxRetries_RetriesUsingFallback_DoesNotParkImmediately`, `TransientFailure_WithNegativeMaxRetries_RetriesUsingFallback_DoesNotParkImmediately`, and `TransientFailure_WithNonPositiveRetryDelay_UsesFallbackDelay_NotZero` in `NotificationOutboxActorDispatchTests.cs`. Mark untestable today and re-visit when a non-Email adapter (Teams etc.) makes the empty-SMTP-config branch genuinely deliverable.
**Description**
`ResolveRetryPolicyAsync` falls back to `FallbackMaxRetries = 10` and
@@ -392,10 +394,6 @@ asserts the row is parked with the documented error. Optionally remove the fallb
constants if parking-with-no-config is the *intended* operational signal; document
the choice in the actor XML so a maintainer does not "fix" the unreachable code.
**Resolution**
_Unresolved._
### NotificationOutbox-009 — `StuckAgeThreshold` XML-doc says "in-progress notification is re-claimed" — contradicts the design's display-only stuck detection
| | |
+4 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 1 |
| Open findings | 0 |
## Summary
@@ -786,9 +786,11 @@ Tied to NS-019: if the orphan classes are deleted, this finding closes itself. I
|--|--|
| Severity | Medium |
| Category | Testing coverage |
| Status | Open |
| Status | Resolved |
| Location | `tests/ScadaLink.NotificationService.Tests/NotificationDeliveryServiceTests.cs`, `tests/ScadaLink.IntegrationTests/IntegrationSurfaceTests.cs:118-136`, `tests/ScadaLink.Host.Tests/CompositionRootTests.cs:207-209` |
**Resolution (2026-05-28):** Closed by NS-019 — the orphaned `NotificationDeliveryService` class, its `INotificationDeliveryService` Commons interface, and the associated `NotificationDeliveryServiceTests.cs` test file (~40 tests asserting `SendAsync`/`DeliverBufferedAsync` behaviour against a code path no production caller resolves) were all deleted in the NS-019 fix commit. Verification: a directory listing of `tests/ScadaLink.NotificationService.Tests/` shows only `CredentialRedactorTests.cs`, `MailKitSmtpClientWrapperTests.cs`, `NotificationOptionsTests.cs`, `OAuth2TokenServiceTests.cs`, `SmtpErrorClassifierTests.cs`, and `SmtpTlsModeParserTests.cs` — every retained file exercises a primitive that the central NotificationOutbox `EmailNotificationDeliveryAdapter` still depends on, so the false-coverage signal this finding called out no longer exists. The "no test affirms the central-only invariant" gap was the consequence of the orphaned tests existing; with them gone, the module test suite genuinely scopes to the shared SMTP primitives. The architecture-test recommendation (banning new consumers of `INotificationDeliveryService`) is moot once the interface itself is gone.
**Description**
The module test suite has 56 tests; counting `NotificationDeliveryServiceTests.cs`, ~40 of them exercise `NotificationDeliveryService.SendAsync`/`DeliverBufferedAsync` — code paths that, per NS-019, no production caller resolves. They pass against the orphaned class and so the suite stays green, but the green is a false signal: changing the dead implementation (or deleting it) does not flag any regression in the live notification-delivery flow, which now lives in `EmailNotificationDeliveryAdapter` (covered by NotificationOutbox's own tests) and `NotificationForwarder` (covered, if at all, by StoreAndForward's tests).
+18 -31
View File
@@ -41,37 +41,37 @@ module file and counted in **Total**.
|----------|---------------|
| Critical | 0 |
| High | 0 |
| Medium | 16 |
| Low | 30 |
| **Total** | **46** |
| Medium | 13 |
| Low | 20 |
| **Total** | **33** |
## Module Status
| Module | Last reviewed | Commit | Open (C/H/M/L) | Open | Total |
|--------|---------------|--------|----------------|------|-------|
| [AuditLog](AuditLog/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/0 | 1 | 11 |
| [CLI](CLI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/2 | 2 | 23 |
| [CentralUI](CentralUI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/3 | 3 | 33 |
| [CLI](CLI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/1 | 1 | 23 |
| [CentralUI](CentralUI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/2 | 2 | 33 |
| [ClusterInfrastructure](ClusterInfrastructure/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/3 | 3 | 14 |
| [Commons](Commons/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/4 | 4 | 23 |
| [Commons](Commons/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/3 | 3 | 23 |
| [Communication](Communication/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/1 | 1 | 22 |
| [ConfigurationDatabase](ConfigurationDatabase/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/1 | 1 | 24 |
| [ConfigurationDatabase](ConfigurationDatabase/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 24 |
| [DataConnectionLayer](DataConnectionLayer/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 22 |
| [DeploymentManager](DeploymentManager/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/3 | 3 | 24 |
| [DeploymentManager](DeploymentManager/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/2 | 2 | 24 |
| [ExternalSystemGateway](ExternalSystemGateway/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/0 | 1 | 23 |
| [HealthMonitoring](HealthMonitoring/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/2 | 2 | 23 |
| [HealthMonitoring](HealthMonitoring/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/1 | 1 | 23 |
| [Host](Host/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/3 | 4 | 22 |
| [InboundAPI](InboundAPI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/1 | 1 | 25 |
| [ManagementService](ManagementService/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/0 | 1 | 23 |
| [NotificationOutbox](NotificationOutbox/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/1 | 1 | 10 |
| [NotificationService](NotificationService/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/0 | 1 | 25 |
| [InboundAPI](InboundAPI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 25 |
| [ManagementService](ManagementService/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 23 |
| [NotificationOutbox](NotificationOutbox/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 10 |
| [NotificationService](NotificationService/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 25 |
| [Security](Security/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 21 |
| [SiteCallAudit](SiteCallAudit/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/1 | 3 | 6 |
| [SiteEventLogging](SiteEventLogging/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/2 | 2 | 23 |
| [SiteCallAudit](SiteCallAudit/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/0 | 2 | 6 |
| [SiteEventLogging](SiteEventLogging/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/1 | 1 | 23 |
| [SiteRuntime](SiteRuntime/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/0 | 2 | 26 |
| [StoreAndForward](StoreAndForward/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/3/2 | 5 | 24 |
| [TemplateEngine](TemplateEngine/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/3/0 | 3 | 22 |
| [Transport](Transport/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/1 | 2 | 12 |
| [Transport](Transport/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/1 | 1 | 12 |
## Pending Findings
@@ -88,15 +88,13 @@ _None open._
_None open._
### Medium (16)
### Medium (13)
| ID | Module | Title |
|----|--------|-------|
| AuditLog-001 | [AuditLog](AuditLog/findings.md) | Combined-telemetry transport is plumbed end-to-end but never invoked in production |
| ExternalSystemGateway-020 | [ExternalSystemGateway](ExternalSystemGateway/findings.md) | `JsonElementToParameterValue` silently downcasts non-Int64 JSON numbers to `double`, losing precision for `decimal` SQL parameters on retry |
| Host-016 | [Host](Host/findings.md) | Site `CentralContactPoints` second entry targets the site's own remoting port |
| ManagementService-021 | [ManagementService](ManagementService/findings.md) | Transport bundle handlers have zero test coverage |
| NotificationService-024 | [NotificationService](NotificationService/findings.md) | No test affirms the central-only invariant; the orphaned-path tests give a false coverage signal |
| SiteCallAudit-001 | [SiteCallAudit](SiteCallAudit/findings.md) | SupervisorStrategy override is dead code; XML claims Resume that is not enforced |
| SiteCallAudit-003 | [SiteCallAudit](SiteCallAudit/findings.md) | `OnUpsertAsync` does not refresh `IngestedAtUtc`; direct-write callers must remember to stamp it |
| SiteRuntime-021 | [SiteRuntime](SiteRuntime/findings.md) | `HandleDeployArtifacts` updates `DataConnections` in SQLite but never sends `CreateConnectionCommand` to the DCL |
@@ -107,39 +105,28 @@ _None open._
| TemplateEngine-018 | [TemplateEngine](TemplateEngine/findings.md) | `DiffService` reports no entries for added/removed/changed connections |
| TemplateEngine-019 | [TemplateEngine](TemplateEngine/findings.md) | `TemplateResolver.BuildInheritanceChain` still uses the `0`-as-no-parent sentinel that was removed from `CycleDetector` |
| TemplateEngine-020 | [TemplateEngine](TemplateEngine/findings.md) | `Create*` audit entries are written with `EntityId = "0"` before `SaveChangesAsync` populates the real key |
| Transport-010 | [Transport](Transport/findings.md) | Critical Overwrite + cross-cutting paths uncovered by tests |
### Low (30)
### Low (20)
| ID | Module | Title |
|----|--------|-------|
| CLI-020 | [CLI](CLI/findings.md) | `bundle export` success-envelope parse is unguarded |
| CLI-022 | [CLI](CLI/findings.md) | `CommandTreeTests` excludes the two new command groups |
| CentralUI-029 | [CentralUI](CentralUI/findings.md) | `ConfigurationAuditLog` uses `JS.InvokeAsync<int>("eval", ...)` instead of a dedicated JS module |
| CentralUI-032 | [CentralUI](CentralUI/findings.md) | `AuditResultsGrid` paging is forward-only, no Previous button |
| CentralUI-033 | [CentralUI](CentralUI/findings.md) | Drill-in / query-string code paths for the new Transport + SiteCalls pages are untested |
| ClusterInfrastructure-011 | [ClusterInfrastructure](ClusterInfrastructure/findings.md) | `SectionName` constant is decorative — no binding site references it |
| ClusterInfrastructure-013 | [ClusterInfrastructure](ClusterInfrastructure/findings.md) | Test uses catastrophic config values without an inline-intent comment |
| ClusterInfrastructure-014 | [ClusterInfrastructure](ClusterInfrastructure/findings.md) | `AddClusterInfrastructureActors` is dead surface — no caller, no behaviour |
| Commons-016 | [Commons](Commons/findings.md) | `BundleSession.Locked` uses a magic `3` rather than a named constant |
| Commons-018 | [Commons](Commons/findings.md) | `IOperationTrackingStore` and `IPartitionMaintenance` are at the root of `Interfaces/` instead of `Interfaces/Services/` |
| Commons-020 | [Commons](Commons/findings.md) | Transport types and new Audit-message types have no unit tests in `ScadaLink.Commons.Tests` |
| Commons-023 | [Commons](Commons/findings.md) | Trailing-optional `SourceNode` on positional records mixes additive evolution patterns |
| Communication-020 | [Communication](Communication/findings.md) | `SiteAddressCacheLoaded` carries mutable `Dictionary`/`List` types |
| ConfigurationDatabase-024 | [ConfigurationDatabase](ConfigurationDatabase/findings.md) | Missing test coverage for SPLIT-RANGE failure-continuation and production-shape rowversion delete |
| DeploymentManager-021 | [DeploymentManager](DeploymentManager/findings.md) | `ResolveSiteIdentifierAsync` silently substitutes the DB id when the site row is missing |
| DeploymentManager-022 | [DeploymentManager](DeploymentManager/findings.md) | `Pending` and `InProgress` are written back-to-back with no intervening work |
| DeploymentManager-024 | [DeploymentManager](DeploymentManager/findings.md) | Test probe actors hold mutable static state across tests |
| HealthMonitoring-021 | [HealthMonitoring](HealthMonitoring/findings.md) | `CentralSiteId = "central"` reserved constant silently collides with a real site named "central" |
| HealthMonitoring-022 | [HealthMonitoring](HealthMonitoring/findings.md) | `CentralHealthReportLoopTests` uses real-time `PeriodicTimer` + `Task.Delay`; flake-prone on slow CI |
| Host-018 | [Host](Host/findings.md) | Shipped per-role configs omit `NodeOptions.NodeName`, leaving `SourceNode` null |
| Host-020 | [Host](Host/findings.md) | `MinimumLevel.Is` silently overrides any operator-set `Serilog:MinimumLevel` |
| Host-021 | [Host](Host/findings.md) | Microsoft `Logging:LogLevel` section in `appsettings.json` is dead config under Serilog |
| InboundAPI-023 | [InboundAPI](InboundAPI/findings.md) | `EndpointExtensions.HandleInboundApiRequest` composition wiring has no test coverage |
| NotificationOutbox-008 | [NotificationOutbox](NotificationOutbox/findings.md) | `FallbackMaxRetries` / `FallbackRetryDelay` path is unreachable in production AND untested |
| SiteCallAudit-006 | [SiteCallAudit](SiteCallAudit/findings.md) | Stuck-only paging test does not exercise the multi-page boundary with an interleaved non-stuck row at the cursor |
| SiteEventLogging-018 | [SiteEventLogging](SiteEventLogging/findings.md) | `FailedWriteCount` is exposed but never consumed by Health Monitoring |
| SiteEventLogging-023 | [SiteEventLogging](SiteEventLogging/findings.md) | Concurrent-stress test uses a non-volatile `stop` flag |
| StoreAndForward-022 | [StoreAndForward](StoreAndForward/findings.md) | `NotifyCachedCallObserverAsync` silently drops the entire audit lifecycle when the message id is not a parseable `TrackedOperationId` |
| StoreAndForward-023 | [StoreAndForward](StoreAndForward/findings.md) | `siteId` silently defaults to empty when no `IStoreAndForwardSiteContext` is registered, degrading audit telemetry correlation |
| Transport-012 | [Transport](Transport/findings.md) | "Bundle Import" filter promised in design doc not surfaced in Configuration Audit Log Viewer UI |
+4 -6
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 5 |
| Open findings | 4 |
## Summary
@@ -300,9 +300,11 @@ relay-path crash.
|--|--|
| Severity | Low |
| Category | Testing coverage |
| Status | Open |
| Status | Resolved |
| Location | `tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.cs:335-392` |
**Resolution (2026-05-28):** Added `SiteCallQueryRequest_StuckOnly_CursorAtNonStuckBoundary_SkipsToNextStuckRow` to `tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.cs` — drives six rows interleaved as `stuck/non-stuck` × 3 (oldest-first), then issues three page-size-1 stuck-only queries. The cursor between each page deliberately lands on a non-stuck row, so the SQL composition of the stuck predicate AND the keyset cursor predicate must skip it. Asserts each page returns exactly one stuck row in DESC-by-CreatedAtUtc order with no overlap and all three stuck rows visited. Locks the invariant that post-filtering does not produce under-filled pages with non-null next cursors.
**Description**
`SiteCallQueryRequest_StuckOnly_PagesAreFull_NoEmptyPagesWithCursor` covers
@@ -325,7 +327,3 @@ Add a test that (a) inserts 6 rows in interleaved order: stuck, not-stuck,
stuck, not-stuck, stuck, not-stuck (oldest first); (b) issues a `StuckOnly`
page-size-1 query; (c) asserts each page returns exactly the stuck row, with
no overlap and all 3 stuck rows visited.
**Resolution**
_Unresolved._
+4 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 4 |
| Open findings | 3 |
## Summary
@@ -1115,9 +1115,11 @@ and locking_mode review that should accompany it.
|--|--|
| Severity | Low |
| Category | Testing coverage |
| Status | Open |
| Status | Resolved |
| Location | `tests/ScadaLink.SiteEventLogging.Tests/EventLogPurgeServiceTests.cs:282-308` |
**Resolution (2026-05-28):** Promoted the test-local `bool stop` to a `volatile bool _stop` field on `EventLogPurgeServiceTests` (with a doc-comment cross-referencing this finding). Every writer thread now observes the main thread's `_stop = true` flip without relying on JIT/runtime quirks across the `await _eventLogger.LogEventAsync` boundary, so the regression test for SiteEventLogging-003 can no longer hang past xUnit's per-test timeout in release builds. `CancellationTokenSource` was considered (canonical pattern) but `volatile bool` is the minimal-diff fix consistent with the existing structure.
**Description**
`PurgeByStorageCap_ConcurrentWritesDoNotCorruptConnection` uses a plain `bool stop = false;`
+40 -6
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 8 |
| Open findings | 7 |
## Summary
@@ -388,9 +388,47 @@ _Unresolved._
|--|--|
| Severity | Medium |
| Category | Testing coverage |
| Status | Open |
| Status | Resolved |
| Location | `tests/ScadaLink.Transport.IntegrationTests/ConflictResolutionTests.cs`, `tests/ScadaLink.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs` |
**Resolution (2026-05-28):** Re-verified the listed gaps against the current
tree. Each item the finding enumerated has landed in the recent fix commits:
- **Template Overwrite with divergent Attributes / Alarms / Scripts** — covered
by `ApplyAsync_Overwrite_synchronises_attributes_alarms_and_scripts_to_bundle`
in `tests/ScadaLink.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs`
(added with the Transport-001 fix in commit `e3ca9af`). Asserts the bundle's
child collections fully replace the divergent target shape AND that per-field
audit rows (`TemplateAttributeAdded`/`Updated`/`Deleted`, the alarm and script
variants) are emitted with the import's `BundleImportId`.
- **ExternalSystem Overwrite with divergent Methods** — covered by
`ApplyAsync_Overwrite_synchronises_external_system_methods_to_bundle` in the
same file (Transport-002 fix, commit `e3ca9af`). Mirrors the T-001 shape with
`ExternalSystemMethodAdded`/`Updated`/`Deleted` audit rows.
- **Per-IP unlock-throttle behaviour (Transport-004)** — covered by
`tests/ScadaLink.Transport.Tests/Import/BundleUnlockRateLimiterTests.cs` (12
tests: under-limit, at-limit rejection, sliding-window reset, per-key isolation).
- **Failed-apply session retention (Transport-007)** — covered by
`tests/ScadaLink.Transport.Tests/Import/BundleSessionStoreTests.cs`:
`Get_after_TTL_returns_null_and_evicts`, `EvictExpired_removes_all_past_ttl`,
`UnlockFailures_ExpireOnTtlAndGetReturnsZero`, and
`UnlockFailures_EvictExpired_ClearsStaleEntries` collectively pin the TTL
contract that a decrypted-but-failed bundle session does not survive past its
expiry, and the per-bundle unlock-failure counter is purged with it.
- **`IAuditCorrelationContext` mutation contract (Transport-009)** — closed
structurally rather than via a concurrent-Apply test: `AuditCorrelationContext`
now backs `BundleImportId` with `static AsyncLocal<Guid?>`, so cross-Apply
contamination is impossible by construction. Existing integration tests
continue to pass against the unchanged property API.
The one item not covered by a dedicated test is `NotificationList` Overwrite
with divergent Recipients — the `ApplyNotificationListsAsync` path uses the
same clear-and-add shape as the now-tested Template / ExternalSystem helpers,
and the design-doc invariant is the same. Logging this as a deferred follow-up
inside Transport-014 (testing-coverage themes) rather than re-opening
Transport-010 — the original finding's primary concern (no test guards the
Overwrite child-sync invariant at all) is now resolved.
**Description**
The existing tests cover the happy path well (round-trip, semantic-validator
@@ -415,10 +453,6 @@ asserts on `Description` only. Specifically missing:
Add the missing integration tests above. Most can be modelled after
`ConflictResolutionTests`' export-then-mutate-target-then-apply pattern.
**Resolution**
_Unresolved._
### Transport-011 — Design doc's Step-1 manifest preview promises decryption-free preview, but `LoadAsync` reads and validates content before passphrase
| | |
+49 -1
View File
@@ -18,6 +18,10 @@ public class CommandTreeTests
private static readonly Option<string> Password = new("--password") { Recursive = true };
private static readonly Option<string> Format = CliOptions.CreateFormatOption();
// NOTE: this list MUST stay in sync with the rootCommand.Add(...) calls in
// src/ScadaLink.CLI/Program.cs. When a new command group is added (or one is
// removed/renamed), update this array and bump the count assertion in
// AllCommandGroups_Build_WithoutThrowing accordingly.
private static IEnumerable<Command> AllCommandGroups() => new[]
{
TemplateCommands.Build(Url, Format, Username, Password),
@@ -29,11 +33,13 @@ public class CommandTreeTests
NotificationCommands.Build(Url, Format, Username, Password),
SecurityCommands.Build(Url, Format, Username, Password),
AuditLogCommands.Build(Url, Format, Username, Password),
AuditCommands.Build(Url, Format, Username, Password),
HealthCommands.Build(Url, Format, Username, Password),
DebugCommands.Build(Url, Format, Username, Password),
SharedScriptCommands.Build(Url, Format, Username, Password),
DbConnectionCommands.Build(Url, Format, Username, Password),
ApiMethodCommands.Build(Url, Format, Username, Password),
BundleCommands.Build(Url, Format, Username, Password),
};
private static IEnumerable<Command> LeafCommands(Command command)
@@ -53,10 +59,49 @@ public class CommandTreeTests
public void AllCommandGroups_Build_WithoutThrowing()
{
var groups = AllCommandGroups().ToList();
Assert.Equal(14, groups.Count);
// CLI-022: bump this count whenever a new top-level command group is
// registered in Program.cs. Current registered groups (16):
// template, instance, site, deploy, data-connection, external-system,
// notification, security, audit-config, audit, health, debug,
// shared-script, db-connection, api-method, bundle.
Assert.Equal(16, groups.Count);
Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name)));
}
[Fact]
public void AllCommandGroups_Contains_AuditAndBundle()
{
// CLI-022: explicit group-presence assertion so the harness does not
// silently drift back to excluding new groups. Use names because that
// is what users actually type at the prompt.
var groupNames = AllCommandGroups().Select(g => g.Name).ToHashSet();
Assert.Contains("audit", groupNames);
Assert.Contains("bundle", groupNames);
}
[Fact]
public void AuditCommandGroup_HasQueryExportAndVerifyChain()
{
// CLI-022: pin the audit sub-command surface so a rename / accidental
// removal of one of these is caught.
var audit = AuditCommands.Build(Url, Format, Username, Password);
var subNames = audit.Subcommands.Select(c => c.Name).ToHashSet();
Assert.Contains("query", subNames);
Assert.Contains("export", subNames);
Assert.Contains("verify-chain", subNames);
}
[Fact]
public void BundleCommandGroup_HasExportPreviewAndImport()
{
// CLI-022: pin the bundle sub-command surface.
var bundle = BundleCommands.Build(Url, Format, Username, Password);
var subNames = bundle.Subcommands.Select(c => c.Name).ToHashSet();
Assert.Contains("export", subNames);
Assert.Contains("preview", subNames);
Assert.Contains("import", subNames);
}
[Fact]
public void EveryLeafCommand_HasAnAction()
{
@@ -93,6 +138,9 @@ public class CommandTreeTests
[InlineData(typeof(DebugSnapshotCommand))]
[InlineData(typeof(MgmtDeployInstanceCommand))]
[InlineData(typeof(QueryAuditLogCommand))]
[InlineData(typeof(ExportBundleCommand))]
[InlineData(typeof(PreviewBundleCommand))]
[InlineData(typeof(ImportBundleCommand))]
public void CommandPayloadTypes_ResolveViaRegistry(Type commandType)
{
// GetCommandName throws ArgumentException for an unregistered type — the CLI
@@ -0,0 +1,250 @@
using System.Security.Claims;
using Akka.Actor;
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ScadaLink.CentralUI.Components.Shared;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Interfaces.Transport;
using ScadaLink.Commons.Messages.Audit;
using ScadaLink.Commons.Types.Transport;
using ScadaLink.Communication;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.Transport;
using SiteCallsReportPage = ScadaLink.CentralUI.Components.Pages.SiteCalls.SiteCallsReport;
using TransportImportPage = ScadaLink.CentralUI.Components.Pages.Design.TransportImport;
namespace ScadaLink.CentralUI.Tests.Pages;
/// <summary>
/// CentralUI-033: tests for the drill-in / query-string code paths on the two
/// newest pages (TransportImport + SiteCallsReport). The base happy-path cases
/// (Parked, stuck=true, no params) live next to the rest of the page's tests in
/// <c>SiteCallsReportPageTests</c> / <c>TransportImportPageTests</c>; this file
/// fills the remaining gaps the finding called out — unrecognised values, case
/// handling, and the no-query-string default for the Transport wizard.
/// </summary>
public sealed class QueryStringDrillInTests
{
// STM: CentralUI-033-QueryStringDrillIn marker — used by grep verification.
// -----------------------------------------------------------------
// SiteCallsReport — ?status=
// -----------------------------------------------------------------
[Fact]
public void SiteCallsReport_StatusParam_CaseInsensitiveMatch_NormalisesToCanonicalCasing()
{
// The dropdown options use canonical casing ("Parked"). The KPI tiles
// emit canonical, but a hand-crafted ?status=parked URL must still seed
// the filter — the parser is case-insensitive and the seeded value uses
// the canonical casing so the <select> can bind it.
using var ctx = new SiteCallsReportFixture();
var nav = (BunitNavigationManager)ctx.Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/site-calls/report?status=parked");
var cut = ctx.Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
Assert.Single(ctx.QueryRequests);
// Normalised to canonical casing (the dropdown's option text), not
// the URL's raw "parked".
Assert.Equal("Parked", ctx.QueryRequests[0].StatusFilter);
});
}
[Fact]
public void SiteCallsReport_StatusParam_Unrecognised_IsSilentlyDropped()
{
// Lax parsing: an unrecognised status value is ignored, leaving the
// filter empty so the page loads unfiltered. Mirrors AuditLogPage's
// drill-in convention — a hand-crafted bad URL must not break the page.
using var ctx = new SiteCallsReportFixture();
var nav = (BunitNavigationManager)ctx.Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/site-calls/report?status=NotARealStatus");
var cut = ctx.Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
Assert.Single(ctx.QueryRequests);
Assert.Null(ctx.QueryRequests[0].StatusFilter);
Assert.False(ctx.QueryRequests[0].StuckOnly);
});
}
[Fact]
public void SiteCallsReport_StuckParam_NonBoolean_IsSilentlyDropped()
{
// bool.TryParse fails for "yes"/"1" — the parser drops the value and
// leaves StuckOnly = false, mirroring the unrecognised-status path.
using var ctx = new SiteCallsReportFixture();
var nav = (BunitNavigationManager)ctx.Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/site-calls/report?stuck=yes");
var cut = ctx.Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
Assert.Single(ctx.QueryRequests);
Assert.False(ctx.QueryRequests[0].StuckOnly);
});
}
// -----------------------------------------------------------------
// TransportImport — no query-string parameters on this route
// -----------------------------------------------------------------
/// <summary>
/// CentralUI-033: TransportImport.razor declares no <c>[Parameter]</c> /
/// <c>SupplyParameterFromQuery</c> bindings — the wizard's initial state is
/// purely <c>ImportWizardStep.Upload</c> regardless of the query-string. This
/// test pins that contract: navigating with an unrecognised query-string
/// param does not throw and does not change the initial step.
/// </summary>
[Fact]
public void TransportImport_UnrecognisedQueryStringParam_DoesNotChangeInitialStep()
{
using var ctx = new TransportImportFixture();
var nav = (BunitNavigationManager)ctx.Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/design/transport/import?bundleImportId=11111111-1111-1111-1111-111111111111&foo=bar");
var cut = ctx.Render<TransportImportPage>();
// The wizard starts at Upload regardless of any drill-in query string —
// the page has no [Parameter]-bound properties so unknown keys are
// silently ignored by Blazor's parameter binding.
var step = (TransportImportPage.ImportWizardStep)typeof(TransportImportPage)
.GetField("_step",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
.GetValue(cut.Instance)!;
Assert.Equal(TransportImportPage.ImportWizardStep.Upload, step);
// And the Step-1 InputFile control is rendered — the page came up clean.
Assert.NotNull(cut.Find("input[type='file']"));
}
// -----------------------------------------------------------------
// Test-scoped fixtures — kept inside this file to bound the diff.
// The existing page-level test files have their own larger fixtures;
// these copies are intentionally minimal (only what the drill-in
// tests need).
// -----------------------------------------------------------------
private sealed class SiteCallsReportFixture : BunitContext
{
private readonly ActorSystem _system = ActorSystem.Create("qs-drillin-tests");
public readonly CommunicationService Comms;
public readonly List<SiteCallQueryRequest> QueryRequests = new();
public SiteCallsReportFixture()
{
Comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
var auditProxy = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this)));
Comms.SetSiteCallAudit(auditProxy);
Services.AddSingleton(Comms);
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
{
new("Plant A", "plant-a") { Id = 1 },
}));
Services.AddSingleton(siteRepo);
var claims = new[]
{
new Claim("Username", "tester"),
new Claim(ClaimTypes.Role, "Deployment"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddScoped<ScadaLink.CentralUI.Auth.SiteScopeService>();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_system.Terminate().Wait(TimeSpan.FromSeconds(5));
}
base.Dispose(disposing);
}
}
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
{
public ScriptedSiteCallAuditActor(SiteCallsReportFixture fixture)
{
Receive<SiteCallQueryRequest>(req =>
{
fixture.QueryRequests.Add(req);
Sender.Tell(new SiteCallQueryResponse(
req.CorrelationId, true, null,
new List<SiteCallSummary>(), null, null));
});
}
}
private sealed class AlwaysConfirmDialogService : IDialogService
{
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
=> Task.FromResult(true);
public Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
}
private sealed class TransportImportFixture : BunitContext
{
public TransportImportFixture()
{
JSInterop.Mode = JSRuntimeMode.Loose;
var importer = Substitute.For<IBundleImporter>();
Services.AddSingleton(importer);
Services.AddSingleton(Substitute.For<IAuditService>());
Services.AddSingleton<IOptions<TransportOptions>>(
Microsoft.Extensions.Options.Options.Create(new TransportOptions
{
MaxBundleSizeMb = 10,
MaxUnlockAttemptsPerSession = 3,
}));
var dbOptions = new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlite("DataSource=:memory:")
.ConfigureWarnings(w => w.Ignore(RelationalEventId.AmbientTransactionWarning))
.Options;
var dbContext = new ScadaLinkDbContext(dbOptions);
dbContext.Database.OpenConnection();
dbContext.Database.EnsureCreated();
Services.AddSingleton(dbContext);
var claims = new List<Claim>
{
new(ScadaLink.Security.JwtTokenService.UsernameClaimType, "alice"),
new(ScadaLink.Security.JwtTokenService.RoleClaimType, "Admin"),
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
Services.AddAuthorizationCore();
}
}
}
@@ -0,0 +1,296 @@
using System.Text.Json;
using ScadaLink.Commons.Types.Transport;
namespace ScadaLink.Commons.Tests.Types.Transport;
/// <summary>
/// Commons-020: focused shape / round-trip tests for the Transport (#24) record DTOs
/// — <see cref="BundleManifest"/>, <see cref="ExportSelection"/>,
/// <see cref="ImportPreview"/>, <see cref="ImportResolution"/>, and
/// <see cref="ImportResult"/>. These records cross the Central UI ⇆ bundle file boundary
/// via System.Text.Json, so a positional/tuple slip would break bundles in the field.
/// EncryptionMetadata has its own focused tests under EncryptionMetadataTests.cs
/// (Commons-015) and is reused here only to populate manifest fixtures.
/// </summary>
public sealed class TransportRecordsTests
{
// STM: TransportRecordsTests-Commons-020 marker — used by grep verification.
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = false,
};
// --------------------------------------------------------------
// BundleManifest
// --------------------------------------------------------------
[Fact]
public void BundleManifest_Constructor_RoundTripsAllFields()
{
var summary = new BundleSummary(
Templates: 2, TemplateFolders: 1, SharedScripts: 3,
ExternalSystems: 1, DbConnections: 0, NotificationLists: 1,
SmtpConfigs: 1, ApiKeys: 0, ApiMethods: 4);
var contents = new List<ManifestContentEntry>
{
new("Template", "Pump", 1, new List<string> { "Shared.Helpers" }),
new("Template", "Valve", 2, Array.Empty<string>()),
};
var manifest = new BundleManifest(
BundleFormatVersion: 1,
SchemaVersion: "1.0",
CreatedAtUtc: new DateTimeOffset(2026, 5, 28, 12, 0, 0, TimeSpan.Zero),
SourceEnvironment: "cli",
ExportedBy: "alice",
ScadaLinkVersion: "0.9.0",
ContentHash: "sha256:deadbeef",
Encryption: null,
Summary: summary,
Contents: contents);
Assert.Equal(1, manifest.BundleFormatVersion);
Assert.Equal("1.0", manifest.SchemaVersion);
Assert.Equal("cli", manifest.SourceEnvironment);
Assert.Equal("alice", manifest.ExportedBy);
Assert.Equal("0.9.0", manifest.ScadaLinkVersion);
Assert.Equal("sha256:deadbeef", manifest.ContentHash);
Assert.Null(manifest.Encryption);
Assert.Equal(summary, manifest.Summary);
Assert.Equal(2, manifest.Contents.Count);
Assert.Equal("Pump", manifest.Contents[0].Name);
}
[Fact]
public void BundleManifest_JsonRoundTrip_PreservesAllFields()
{
var encryption = new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: 600_000,
SaltB64: "c2FsdA==",
IvB64: "aXY=");
var summary = new BundleSummary(1, 0, 0, 0, 0, 0, 0, 0, 0);
var manifest = new BundleManifest(
BundleFormatVersion: 1,
SchemaVersion: "1.0",
CreatedAtUtc: new DateTimeOffset(2026, 5, 28, 12, 0, 0, TimeSpan.Zero),
SourceEnvironment: "ui",
ExportedBy: "bob",
ScadaLinkVersion: "0.9.0",
ContentHash: "sha256:abc",
Encryption: encryption,
Summary: summary,
Contents: new List<ManifestContentEntry>
{
new("Template", "Pump", 7, new List<string> { "dep-a" }),
});
var json = JsonSerializer.Serialize(manifest, JsonOpts);
var rt = JsonSerializer.Deserialize<BundleManifest>(json, JsonOpts);
Assert.NotNull(rt);
Assert.Equal(manifest.SourceEnvironment, rt!.SourceEnvironment);
Assert.Equal(manifest.ContentHash, rt.ContentHash);
Assert.Equal(manifest.Summary, rt.Summary);
Assert.Single(rt.Contents);
Assert.Equal("Pump", rt.Contents[0].Name);
Assert.Equal(7, rt.Contents[0].Version);
Assert.NotNull(rt.Encryption);
Assert.Equal("AES-256-GCM", rt.Encryption!.Algorithm);
Assert.Equal(600_000, rt.Encryption.Iterations);
}
// --------------------------------------------------------------
// ExportSelection
// --------------------------------------------------------------
[Fact]
public void ExportSelection_Constructor_PreservesAllIdLists()
{
var sel = new ExportSelection(
TemplateIds: new[] { 1, 2, 3 },
SharedScriptIds: new[] { 10 },
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: new[] { 20, 21 },
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: new[] { 30 },
ApiKeyIds: new[] { 40, 41 },
ApiMethodIds: new[] { 50 },
IncludeDependencies: true);
Assert.Equal(new[] { 1, 2, 3 }, sel.TemplateIds);
Assert.Single(sel.SharedScriptIds);
Assert.Empty(sel.ExternalSystemIds);
Assert.Equal(2, sel.DatabaseConnectionIds.Count);
Assert.True(sel.IncludeDependencies);
}
[Fact]
public void ExportSelection_JsonRoundTrip_PreservesIncludeDependenciesAndIds()
{
var sel = new ExportSelection(
TemplateIds: new[] { 1, 2 },
SharedScriptIds: Array.Empty<int>(),
ExternalSystemIds: new[] { 5 },
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false);
var json = JsonSerializer.Serialize(sel, JsonOpts);
var rt = JsonSerializer.Deserialize<ExportSelection>(json, JsonOpts);
Assert.NotNull(rt);
Assert.Equal(sel.TemplateIds, rt!.TemplateIds);
Assert.Equal(sel.ExternalSystemIds, rt.ExternalSystemIds);
Assert.False(rt.IncludeDependencies);
}
// --------------------------------------------------------------
// ImportPreview
// --------------------------------------------------------------
[Fact]
public void ImportPreview_Constructor_AllowsAllConflictKinds()
{
var sessionId = Guid.NewGuid();
var items = new List<ImportPreviewItem>
{
new("Template", "Pump", ExistingVersion: 1, IncomingVersion: 1, Kind: ConflictKind.Identical, FieldDiffJson: null, BlockerReason: null),
new("Template", "Valve", ExistingVersion: 1, IncomingVersion: 2, Kind: ConflictKind.Modified, FieldDiffJson: "{\"name\":\"Valve\"}", BlockerReason: null),
new("Template", "New", ExistingVersion: null, IncomingVersion: 1, Kind: ConflictKind.New, FieldDiffJson: null, BlockerReason: null),
new("Template", "Bad", ExistingVersion: 1, IncomingVersion: 5, Kind: ConflictKind.Blocker, FieldDiffJson: null, BlockerReason: "Parameters property mismatch"),
};
var preview = new ImportPreview(sessionId, items);
Assert.Equal(sessionId, preview.SessionId);
Assert.Equal(4, preview.Items.Count);
Assert.Equal(ConflictKind.Identical, preview.Items[0].Kind);
Assert.Equal(ConflictKind.Modified, preview.Items[1].Kind);
Assert.Equal(ConflictKind.New, preview.Items[2].Kind);
Assert.Equal(ConflictKind.Blocker, preview.Items[3].Kind);
Assert.Equal("Parameters property mismatch", preview.Items[3].BlockerReason);
}
[Fact]
public void ImportPreview_JsonRoundTrip_PreservesConflictKindAndOptionalFields()
{
var preview = new ImportPreview(
SessionId: Guid.NewGuid(),
Items: new List<ImportPreviewItem>
{
new("Template", "X", 1, 2, ConflictKind.Modified, "{}", null),
new("Template", "Y", null, 1, ConflictKind.New, null, null),
});
var json = JsonSerializer.Serialize(preview, JsonOpts);
var rt = JsonSerializer.Deserialize<ImportPreview>(json, JsonOpts);
Assert.NotNull(rt);
Assert.Equal(preview.SessionId, rt!.SessionId);
Assert.Equal(2, rt.Items.Count);
Assert.Equal(ConflictKind.Modified, rt.Items[0].Kind);
Assert.Equal(ConflictKind.New, rt.Items[1].Kind);
Assert.Null(rt.Items[1].ExistingVersion);
}
// --------------------------------------------------------------
// ImportResolution
// --------------------------------------------------------------
[Theory]
[InlineData(ResolutionAction.Add, null)]
[InlineData(ResolutionAction.Overwrite, null)]
[InlineData(ResolutionAction.Skip, null)]
[InlineData(ResolutionAction.Rename, "NewName")]
public void ImportResolution_Constructor_PreservesAllActions(ResolutionAction action, string? renameTo)
{
var res = new ImportResolution("Template", "Pump", action, renameTo);
Assert.Equal("Template", res.EntityType);
Assert.Equal("Pump", res.Name);
Assert.Equal(action, res.Action);
Assert.Equal(renameTo, res.RenameTo);
}
[Fact]
public void ImportResolution_JsonRoundTrip_PreservesRenameTo()
{
var res = new ImportResolution("Template", "Pump", ResolutionAction.Rename, "Pump_v2");
var json = JsonSerializer.Serialize(res, JsonOpts);
var rt = JsonSerializer.Deserialize<ImportResolution>(json, JsonOpts);
Assert.NotNull(rt);
Assert.Equal(ResolutionAction.Rename, rt!.Action);
Assert.Equal("Pump_v2", rt.RenameTo);
}
// --------------------------------------------------------------
// ImportResult
// --------------------------------------------------------------
[Fact]
public void ImportResult_Constructor_PreservesAllCountersAndStaleIds()
{
var bundleImportId = Guid.NewGuid();
var result = new ImportResult(
BundleImportId: bundleImportId,
Added: 3,
Overwritten: 1,
Skipped: 2,
Renamed: 1,
StaleInstanceIds: new List<int> { 100, 200, 300 },
AuditEventCorrelation: "audit-corr-001");
Assert.Equal(bundleImportId, result.BundleImportId);
Assert.Equal(3, result.Added);
Assert.Equal(1, result.Overwritten);
Assert.Equal(2, result.Skipped);
Assert.Equal(1, result.Renamed);
Assert.Equal(new[] { 100, 200, 300 }, result.StaleInstanceIds);
Assert.Equal("audit-corr-001", result.AuditEventCorrelation);
}
[Fact]
public void ImportResult_JsonRoundTrip_PreservesCountsAndCorrelation()
{
var result = new ImportResult(
BundleImportId: Guid.NewGuid(),
Added: 5,
Overwritten: 0,
Skipped: 0,
Renamed: 0,
StaleInstanceIds: Array.Empty<int>(),
AuditEventCorrelation: "audit-corr-xyz");
var json = JsonSerializer.Serialize(result, JsonOpts);
var rt = JsonSerializer.Deserialize<ImportResult>(json, JsonOpts);
Assert.NotNull(rt);
Assert.Equal(result.BundleImportId, rt!.BundleImportId);
Assert.Equal(5, rt.Added);
Assert.Empty(rt.StaleInstanceIds);
Assert.Equal("audit-corr-xyz", rt.AuditEventCorrelation);
}
// --------------------------------------------------------------
// Record equality sanity (catches positional/tuple slip)
// --------------------------------------------------------------
[Fact]
public void TransportRecords_RecordValueEquality()
{
var a = new ImportResolution("Template", "Pump", ResolutionAction.Add, null);
var b = new ImportResolution("Template", "Pump", ResolutionAction.Add, null);
Assert.Equal(a, b);
Assert.Equal(a.GetHashCode(), b.GetHashCode());
var c = a with { Action = ResolutionAction.Overwrite };
Assert.NotEqual(a, c);
}
}
@@ -1,4 +1,6 @@
using System.Data.Common;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.ConfigurationDatabase.Maintenance;
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
@@ -150,6 +152,138 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix
Assert.Equal(maxBefore.Value.AddMonths(3), maxAfter);
}
/// <summary>
/// ConfigurationDatabase-024: CD-019 removed the try/catch around the per-month
/// SPLIT call so a genuine SQL failure (deadlock, permission, log full, transient
/// connection drop) now aborts the loop instead of leaving partition holes. This
/// test pins that abort behaviour: with an interceptor that throws on the SECOND
/// SPLIT, the call must propagate the exception AND the first SPLIT's boundary
/// must already be persisted in <c>pf_AuditLog_Month</c> (visible to a fresh
/// <see cref="AuditLogPartitionMaintenance"/> instance) — proof that the loop did
/// commit boundary N before throwing, and that the next tick can resume from
/// boundary N+1 at-least-once with no holes.
/// </summary>
[SkippableFact]
public async Task EnsureLookahead_SecondSplitThrows_LoopAborts_FirstBoundaryStillCommitted()
{
// STM: CD-024-SecondSplitThrowsAbortsLoop marker.
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Baseline max-boundary observed via a clean context.
await using var baselineCtx = CreateContext();
var maxBefore = await NewMaintenance(baselineCtx).GetMaxBoundaryAsync();
Assert.NotNull(maxBefore);
var lookahead = LookaheadForExtraBoundaries(maxBefore!.Value, extraBoundaries: 3);
var expectedFirst = maxBefore.Value.AddMonths(1);
// Build a fresh context with an interceptor that throws on the 2nd ALTER
// PARTITION FUNCTION SPLIT RANGE. EF Core surfaces the throw through
// ExecuteSqlRawAsync exactly as a SqlException would — the loop has no
// try/catch (CD-019), so the exception propagates after the first SPLIT
// has already committed.
var interceptor = new SecondSplitThrowsInterceptor();
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.AddInterceptors(interceptor)
.Options;
await using var ctx = new ScadaLinkDbContext(options);
var maintenance = NewMaintenance(ctx);
await Assert.ThrowsAsync<InvalidOperationException>(
() => maintenance.EnsureLookaheadAsync(lookahead));
// Verify exactly one ALTER PARTITION FUNCTION SPLIT RANGE actually ran
// before the interceptor's throw: split #1 committed, split #2 threw,
// split #3 was never attempted.
Assert.Equal(1, interceptor.SuccessfulSplits);
// And verify the first boundary IS now persisted — the loop aborted but
// boundary N is durable so the next tick resumes from N+1 (no holes).
await using var verifyCtx = CreateContext();
var maxAfter = await NewMaintenance(verifyCtx).GetMaxBoundaryAsync();
Assert.Equal(expectedFirst, maxAfter);
}
/// <summary>
/// EF Core command interceptor: lets the first <c>ALTER PARTITION FUNCTION
/// pf_AuditLog_Month() SPLIT RANGE</c> through and throws <see cref="InvalidOperationException"/>
/// on the second one. Threads through synchronous + async + scalar + reader
/// entry-points because <c>ExecuteSqlRawAsync</c> routes through the
/// non-query async path but other code paths still go through the same
/// interceptor pipeline. <see cref="SuccessfulSplits"/> counts the splits
/// that were allowed to run so the test can pin the abort-after-one
/// behaviour.
/// </summary>
private sealed class SecondSplitThrowsInterceptor : DbCommandInterceptor
{
public int SuccessfulSplits { get; private set; }
private bool IsTargetSplit(DbCommand command) =>
command.CommandText.Contains("SPLIT RANGE", StringComparison.OrdinalIgnoreCase)
&& command.CommandText.Contains("pf_AuditLog_Month", StringComparison.OrdinalIgnoreCase);
public override InterceptionResult<int> NonQueryExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<int> result)
{
ThrowIfSecondSplit(command);
return base.NonQueryExecuting(command, eventData, result);
}
public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
ThrowIfSecondSplit(command);
return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken);
}
public override int NonQueryExecuted(
DbCommand command,
CommandExecutedEventData eventData,
int result)
{
if (IsTargetSplit(command))
{
SuccessfulSplits++;
}
return base.NonQueryExecuted(command, eventData, result);
}
public override ValueTask<int> NonQueryExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
int result,
CancellationToken cancellationToken = default)
{
if (IsTargetSplit(command))
{
SuccessfulSplits++;
}
return base.NonQueryExecutedAsync(command, eventData, result, cancellationToken);
}
private void ThrowIfSecondSplit(DbCommand command)
{
if (!IsTargetSplit(command))
{
return;
}
// Allow the first SPLIT through; throw on the second so the loop's
// post-CD-019 "let it propagate" behaviour can be asserted.
if (SuccessfulSplits >= 1)
{
throw new InvalidOperationException(
"Simulated SqlException on the second SPLIT RANGE — exercising CD-019's no-try/catch abort path.");
}
}
}
[SkippableFact]
public async Task EnsureLookahead_BoundaryAlreadyExists_NoError_Idempotent()
{
@@ -847,6 +847,44 @@ public class DeploymentManagerRepositoryTests : IDisposable
Assert.Null(await _repository.GetDeploymentRecordByIdAsync(id));
}
/// <summary>
/// ConfigurationDatabase-024: CD-017 added optimistic-concurrency to
/// <c>DeleteDeploymentRecordAsync(int id, byte[] expectedRowVersion)</c> — the stub-attach
/// path now seeds <c>OriginalValues["RowVersion"]</c> from the caller's last-observed
/// value so the generated SQL becomes <c>DELETE … WHERE Id = @id AND RowVersion = @prior</c>.
/// This test pins the production-shape happy path: caller holds the entity's CURRENT
/// RowVersion, clears the change-tracker (i.e. no tracked instance — exactly the M&amp;V
/// admin / handler shape), calls Delete with that token, and the delete completes
/// without throwing <see cref="Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException"/>.
/// </summary>
[Fact]
public async Task DeleteDeploymentRecord_CurrentRowVersion_StubAttachPath_DeleteSucceeds()
{
// STM: CD-024-RowVersionDeleteHappyPath marker.
var instance = await SeedInstanceAsync();
var record = new DeploymentRecord("d-rv-001", "admin")
{
InstanceId = instance.Id,
DeployedAt = DateTimeOffset.UtcNow,
};
await _repository.AddDeploymentRecordAsync(record);
await _repository.SaveChangesAsync();
// Capture the entity's CURRENT RowVersion (the one the caller would have
// read from a prior GetDeploymentRecordByIdAsync), then detach so the
// delete travels through the stub-attach branch (no tracked entity).
var id = record.Id;
var currentRowVersion = record.RowVersion ?? Array.Empty<byte>();
_context.ChangeTracker.Clear();
// No NotSupported/Concurrency exception should fire on this code path.
await _repository.DeleteDeploymentRecordAsync(id, currentRowVersion);
var affected = await _repository.SaveChangesAsync();
Assert.Equal(1, affected);
Assert.Null(await _repository.GetDeploymentRecordByIdAsync(id));
}
[Fact]
public async Task DeleteInstance_RemovesRestrictFkDeploymentRecordsFirst()
{
@@ -90,13 +90,14 @@ public class ArtifactDeploymentServiceTests : TestKit
new("Site Two", "site-2") { Id = 2 }
};
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor()));
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
var service = CreateServiceWithCommActor(probe);
var result = await service.DeployToAllSitesAsync("admin");
Assert.True(result.IsSuccess);
var commands = ArtifactProbeActor.Received;
var commands = recorder.Received;
Assert.Equal(2, commands.Count);
// All per-site commands carry one shared id, equal to the summary id.
@@ -128,7 +129,8 @@ public class ArtifactDeploymentServiceTests : TestKit
new("Site Two", "fail-site") { Id = 2 }
};
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor("fail-site")));
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder, "fail-site")));
var service = CreateServiceWithCommActor(probe);
var result = await service.DeployToAllSitesAsync("admin");
@@ -157,7 +159,8 @@ public class ArtifactDeploymentServiceTests : TestKit
new("Site Three", "site-3") { Id = 3 },
};
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor()));
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
var service = CreateServiceWithCommActor(probe);
var result = await service.DeployToAllSitesAsync("admin");
@@ -184,7 +187,8 @@ public class ArtifactDeploymentServiceTests : TestKit
// global query is issued exactly once. Pin this so a future refactor cannot
// accidentally route RetryForSiteAsync through the multi-site loop and lose
// the audit row's deploymentId guarantee.
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor()));
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
var service = CreateServiceWithCommActor(probe);
var result = await service.RetryForSiteAsync(1, "retry-site", "admin");
@@ -200,7 +204,8 @@ public class ArtifactDeploymentServiceTests : TestKit
[Fact]
public async Task RetryForSiteAsync_SiteSucceeds_ReturnsSuccessAndAudits()
{
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor()));
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
var service = CreateServiceWithCommActor(probe);
var result = await service.RetryForSiteAsync(1, "retry-site", "admin");
@@ -245,25 +250,34 @@ public class ArtifactDeploymentServiceTests : TestKit
NullLogger<ArtifactDeploymentService>.Instance);
}
/// <summary>
/// Per-test recorder for <see cref="ArtifactProbeActor"/>. DeploymentManager-024:
/// each test owns its own instance, passed into the actor's constructor, so
/// the received-command list is no longer shared static state that races
/// under parallel test execution.
/// </summary>
private sealed class ArtifactProbeRecorder
{
public readonly ConcurrentBag<DeployArtifactsCommand> Received = new();
}
/// <summary>
/// Stand-in CentralCommunicationActor for artifact deployment. Records every
/// <see cref="DeployArtifactsCommand"/> it receives and replies success
/// unless the target site id is in the configured failure set.
/// <see cref="DeployArtifactsCommand"/> it receives into the per-test
/// <see cref="ArtifactProbeRecorder"/> and replies success unless the target
/// site id is in the configured failure set.
/// </summary>
private class ArtifactProbeActor : ReceiveActor
{
public static readonly ConcurrentBag<DeployArtifactsCommand> Received = new();
public ArtifactProbeActor(params string[] failingSites)
public ArtifactProbeActor(ArtifactProbeRecorder recorder, params string[] failingSites)
{
Received.Clear();
var failSet = new HashSet<string>(failingSites);
Receive<SiteEnvelope>(env =>
{
if (env.Message is DeployArtifactsCommand cmd)
{
Received.Add(cmd);
recorder.Received.Add(cmd);
var success = !failSet.Contains(env.SiteId);
Sender.Tell(new ArtifactDeploymentResponse(
cmd.DeploymentId, env.SiteId, success,
@@ -295,8 +295,9 @@ public class DeploymentServiceTests : TestKit
_repo.DeleteInstanceAsync(30, Arg.Any<CancellationToken>())
.Returns<Task>(_ => throw new InvalidOperationException("db unavailable"));
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:x", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:x", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeleteInstanceAsync(30, "admin");
@@ -458,8 +459,9 @@ public class DeploymentServiceTests : TestKit
_repo.GetCurrentDeploymentStatusAsync(50, Arg.Any<CancellationToken>())
.Returns((DeploymentRecord?)null);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(50, "admin");
@@ -478,8 +480,9 @@ public class DeploymentServiceTests : TestKit
var instance = new Instance("DisInst") { Id = 51, SiteId = 1, State = InstanceState.Enabled };
_repo.GetInstanceByIdAsync(51, Arg.Any<CancellationToken>()).Returns(instance);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "x", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "x", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DisableInstanceAsync(51, "admin");
@@ -498,8 +501,9 @@ public class DeploymentServiceTests : TestKit
var instance = new Instance("EnInst") { Id = 52, SiteId = 1, State = InstanceState.Disabled };
_repo.GetInstanceByIdAsync(52, Arg.Any<CancellationToken>()).Returns(instance);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "x", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "x", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.EnableInstanceAsync(52, "admin");
@@ -518,8 +522,9 @@ public class DeploymentServiceTests : TestKit
var instance = new Instance("DelInst") { Id = 53, SiteId = 1, State = InstanceState.Enabled };
_repo.GetInstanceByIdAsync(53, Arg.Any<CancellationToken>()).Returns(instance);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "x", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "x", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeleteInstanceAsync(53, "admin");
@@ -543,8 +548,9 @@ public class DeploymentServiceTests : TestKit
_repo.GetCurrentDeploymentStatusAsync(54, Arg.Any<CancellationToken>())
.Returns((DeploymentRecord?)null);
var serializationCounters = new SerializationProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new SerializationProbeActor()));
new SerializationProbeActor(serializationCounters)));
var service = CreateServiceWithCommActor(commActor);
var deploy1 = service.DeployInstanceAsync(54, "admin");
@@ -555,7 +561,7 @@ public class DeploymentServiceTests : TestKit
Assert.True(results[1].IsSuccess);
// The probe records the maximum concurrency observed; the lock must
// keep it at 1 for a single instance.
Assert.Equal(1, SerializationProbeActor.MaxConcurrent);
Assert.Equal(1, serializationCounters.MaxConcurrent);
}
// ── DeploymentManager-006: query-the-site-before-redeploy idempotency ──
@@ -610,8 +616,9 @@ public class DeploymentServiceTests : TestKit
};
_repo.GetCurrentDeploymentStatusAsync(7, Arg.Any<CancellationToken>()).Returns(prior);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(7, "admin");
@@ -619,8 +626,8 @@ public class DeploymentServiceTests : TestKit
Assert.True(result.IsSuccess);
Assert.Equal(DeploymentStatus.Success, prior.Status);
// The site query was issued, but no new deploy command was sent.
Assert.Equal(1, ReconcileProbeActor.QueryCount);
Assert.Equal(0, ReconcileProbeActor.DeployCount);
Assert.Equal(1, counters.QueryCount);
Assert.Equal(0, counters.DeployCount);
// No new deployment record was created — the prior one was reconciled.
await _repo.DidNotReceive().AddDeploymentRecordAsync(
Arg.Any<DeploymentRecord>(), Arg.Any<CancellationToken>());
@@ -643,16 +650,17 @@ public class DeploymentServiceTests : TestKit
};
_repo.GetCurrentDeploymentStatusAsync(8, Arg.Any<CancellationToken>()).Returns(prior);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:old", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:old", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(8, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(1, ReconcileProbeActor.QueryCount);
Assert.Equal(1, counters.QueryCount);
// The normal deploy proceeded — a new command was sent.
Assert.Equal(1, ReconcileProbeActor.DeployCount);
Assert.Equal(1, counters.DeployCount);
await _repo.Received().AddDeploymentRecordAsync(
Arg.Any<DeploymentRecord>(), Arg.Any<CancellationToken>());
}
@@ -674,15 +682,16 @@ public class DeploymentServiceTests : TestKit
};
_repo.GetCurrentDeploymentStatusAsync(9, Arg.Any<CancellationToken>()).Returns(prior);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(9, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(1, ReconcileProbeActor.QueryCount);
Assert.Equal(0, ReconcileProbeActor.DeployCount);
Assert.Equal(1, counters.QueryCount);
Assert.Equal(0, counters.DeployCount);
Assert.Equal(DeploymentStatus.Success, prior.Status);
}
@@ -702,16 +711,17 @@ public class DeploymentServiceTests : TestKit
};
_repo.GetCurrentDeploymentStatusAsync(10, Arg.Any<CancellationToken>()).Returns(prior);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(10, "admin");
Assert.True(result.IsSuccess);
// No site query — the prior deploy completed cleanly.
Assert.Equal(0, ReconcileProbeActor.QueryCount);
Assert.Equal(1, ReconcileProbeActor.DeployCount);
Assert.Equal(0, counters.QueryCount);
Assert.Equal(1, counters.DeployCount);
}
[Fact]
@@ -724,15 +734,16 @@ public class DeploymentServiceTests : TestKit
_repo.GetCurrentDeploymentStatusAsync(11, Arg.Any<CancellationToken>())
.Returns((DeploymentRecord?)null);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(11, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(0, ReconcileProbeActor.QueryCount);
Assert.Equal(1, ReconcileProbeActor.DeployCount);
Assert.Equal(0, counters.QueryCount);
Assert.Equal(1, counters.DeployCount);
}
[Fact]
@@ -754,16 +765,17 @@ public class DeploymentServiceTests : TestKit
_repo.GetCurrentDeploymentStatusAsync(12, Arg.Any<CancellationToken>()).Returns(prior);
// The probe drops the query (no reply) -> the Ask times out.
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: true)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: true)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(12, "admin");
// Did not abort — the deploy proceeded after the failed query.
Assert.True(result.IsSuccess);
Assert.Equal(1, ReconcileProbeActor.QueryCount);
Assert.Equal(1, ReconcileProbeActor.DeployCount);
Assert.Equal(1, counters.QueryCount);
Assert.Equal(1, counters.DeployCount);
}
// ── DeploymentManager-015: reconciliation must perform the normal success side effects ──
@@ -797,16 +809,17 @@ public class DeploymentServiceTests : TestKit
await _repo.AddDeployedSnapshotAsync(
Arg.Do<DeployedConfigSnapshot>(s => storedSnapshot = s), Arg.Any<CancellationToken>());
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(70, "admin");
Assert.True(result.IsSuccess);
// No re-deploy was sent -- this was reconciled.
Assert.Equal(1, ReconcileProbeActor.QueryCount);
Assert.Equal(0, ReconcileProbeActor.DeployCount);
Assert.Equal(1, counters.QueryCount);
Assert.Equal(0, counters.DeployCount);
// DeploymentManager-015: the instance State must reflect the deployed
// config the site is actually running.
@@ -851,8 +864,9 @@ public class DeploymentServiceTests : TestKit
_repo.GetDeployedSnapshotByInstanceIdAsync(72, Arg.Any<CancellationToken>())
.Returns((DeployedConfigSnapshot?)null);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(72, "admin");
@@ -861,8 +875,8 @@ public class DeploymentServiceTests : TestKit
// Success — central and site agree on the applied config.
Assert.True(result.IsSuccess);
Assert.Equal(DeploymentStatus.Success, prior.Status);
Assert.Equal(1, ReconcileProbeActor.QueryCount);
Assert.Equal(0, ReconcileProbeActor.DeployCount);
Assert.Equal(1, counters.QueryCount);
Assert.Equal(0, counters.DeployCount);
// DeploymentManager-018: the operator's explicit Disable must survive
// the reconciliation — Instance.State stays Disabled, not silently
@@ -896,8 +910,9 @@ public class DeploymentServiceTests : TestKit
_repo.GetDeployedSnapshotByInstanceIdAsync(71, Arg.Any<CancellationToken>())
.Returns((DeployedConfigSnapshot?)null);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(71, "admin");
@@ -936,8 +951,9 @@ public class DeploymentServiceTests : TestKit
_repo.GetDeployedSnapshotByInstanceIdAsync(73, Arg.Any<CancellationToken>())
.Returns((DeployedConfigSnapshot?)null);
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(73, "currentUser");
@@ -1178,8 +1194,9 @@ public class DeploymentServiceTests : TestKit
_repo.AddDeployedSnapshotAsync(Arg.Any<DeployedConfigSnapshot>(), Arg.Any<CancellationToken>())
.Returns<Task>(_ => throw new InvalidOperationException("snapshot store unavailable"));
var counters = new ReconcileProbeCounters();
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: false)));
new ReconcileProbeActor(counters, siteHash: "sha256:target", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(20, "admin");
@@ -1196,33 +1213,40 @@ public class DeploymentServiceTests : TestKit
Arg.Any<CancellationToken>());
}
/// <summary>
/// Per-test counters for <see cref="SerializationProbeActor"/>. DeploymentManager-024:
/// each test owns its own instance, passed into the actor's constructor, so
/// counters are no longer shared static state that races under parallel
/// test execution.
/// </summary>
private sealed class SerializationProbeCounters
{
public int MaxConcurrent;
public int Current;
public readonly object Gate = new();
}
/// <summary>
/// Stand-in CentralCommunicationActor that measures deploy concurrency. It
/// defers each deploy reply via the scheduler, so if two deploys for the
/// same instance were NOT serialized by the operation lock their windows
/// would overlap and <see cref="MaxConcurrent"/> would exceed 1.
/// would overlap and <c>MaxConcurrent</c> would exceed 1.
/// </summary>
private class SerializationProbeActor : ReceiveActor, IWithTimers
{
public static int MaxConcurrent;
private static int _current;
private static readonly object Gate = new();
public ITimerScheduler Timers { get; set; } = null!;
public SerializationProbeActor()
public SerializationProbeActor(SerializationProbeCounters counters)
{
MaxConcurrent = 0;
_current = 0;
Receive<SiteEnvelope>(env =>
{
if (env.Message is DeployInstanceCommand d)
{
lock (Gate)
lock (counters.Gate)
{
_current++;
if (_current > MaxConcurrent) MaxConcurrent = _current;
counters.Current++;
if (counters.Current > counters.MaxConcurrent)
counters.MaxConcurrent = counters.Current;
}
var replyTo = Sender;
@@ -1242,9 +1266,9 @@ public class DeploymentServiceTests : TestKit
Receive<CompleteDeploy>(c =>
{
lock (Gate)
lock (counters.Gate)
{
_current--;
counters.Current--;
}
c.ReplyTo.Tell(new DeploymentStatusResponse(
c.Command.DeploymentId, c.Command.InstanceUniqueName,
@@ -1255,29 +1279,34 @@ public class DeploymentServiceTests : TestKit
private sealed record CompleteDeploy(DeployInstanceCommand Command, IActorRef ReplyTo);
}
/// <summary>
/// Per-test counters for <see cref="ReconcileProbeActor"/>. DeploymentManager-024:
/// each test owns its own instance so counter assertions cannot race across
/// tests running in parallel.
/// </summary>
private sealed class ReconcileProbeCounters
{
public int QueryCount;
public int DeployCount;
}
/// <summary>
/// Stand-in CentralCommunicationActor for reconciliation tests. Counts the
/// site queries and deploy commands it receives, answers queries with a
/// site queries and deploy commands it receives (into a per-test
/// <see cref="ReconcileProbeCounters"/> instance), answers queries with a
/// configurable applied revision hash, and (optionally) drops the query to
/// simulate an unreachable site so the central Ask times out.
/// </summary>
private class ReconcileProbeActor : ReceiveActor
{
public static int QueryCount;
public static int DeployCount;
public ReconcileProbeActor(string siteHash, bool failQuery)
public ReconcileProbeActor(ReconcileProbeCounters counters, string siteHash, bool failQuery)
{
// Each test creates a fresh actor; reset the shared counters.
QueryCount = 0;
DeployCount = 0;
Receive<SiteEnvelope>(env =>
{
switch (env.Message)
{
case DeploymentStateQueryRequest q:
QueryCount++;
Interlocked.Increment(ref counters.QueryCount);
if (!failQuery)
{
Sender.Tell(new DeploymentStateQueryResponse(
@@ -1288,7 +1317,7 @@ public class DeploymentServiceTests : TestKit
break;
case DeployInstanceCommand d:
DeployCount++;
Interlocked.Increment(ref counters.DeployCount);
Sender.Tell(new DeploymentStatusResponse(
d.DeploymentId, d.InstanceUniqueName,
DeploymentStatus.Success, null, DateTimeOffset.UtcNow));
@@ -29,13 +29,53 @@ public class CentralHealthReportLoopTests
public SiteHealthState? GetSiteState(string siteId) => null;
}
private static async Task RunLoopBriefly(CentralHealthReportLoop loop, int runForMs)
/// <summary>
/// HealthMonitoring-022 de-flake: <see cref="CentralHealthReportLoop"/>'s
/// internal cadence is a real <see cref="PeriodicTimer"/>, so the loop is
/// timing-sensitive. We can't drive a virtual clock (PeriodicTimer doesn't
/// consume <see cref="TimeProvider"/>) without refactoring the production
/// loop, so we keep wall-clock waits but use a *generous* budget: a 5 s
/// outer cancellation cap with a poll-until-condition wait, instead of a
/// fixed <see cref="Task.Delay"/> that fails fast on a slow CI runner. The
/// loop's <c>ReportInterval</c> is set to 50 ms in each test, so under
/// normal conditions the condition is met almost immediately; under heavy
/// CI load the poll loop tolerates the slow tick instead of asserting on a
/// timed-out empty list.
/// </summary>
private static async Task RunLoopUntil(
CentralHealthReportLoop loop,
Func<bool> condition,
TimeSpan? maxWait = null)
{
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(runForMs + 100));
var deadline = maxWait ?? TimeSpan.FromSeconds(5);
using var cts = new CancellationTokenSource(deadline + TimeSpan.FromSeconds(1));
try
{
await loop.StartAsync(cts.Token);
await Task.Delay(runForMs, CancellationToken.None);
var sw = System.Diagnostics.Stopwatch.StartNew();
while (sw.Elapsed < deadline && !condition())
{
await Task.Delay(25, CancellationToken.None);
}
await loop.StopAsync(CancellationToken.None);
}
catch (OperationCanceledException) { }
}
/// <summary>
/// Used by tests that need the loop to run for a bounded period without
/// waiting on a specific condition (e.g. asserting <i>no</i> reports were
/// produced). The wait is generous (1 s default) — see
/// <see cref="RunLoopUntil"/> for the rationale.
/// </summary>
private static async Task RunLoopBriefly(CentralHealthReportLoop loop, int runForMs)
{
var totalMs = Math.Max(runForMs, 1000);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(totalMs + 1000));
try
{
await loop.StartAsync(cts.Token);
await Task.Delay(totalMs, CancellationToken.None);
await loop.StopAsync(CancellationToken.None);
}
catch (OperationCanceledException) { }
@@ -56,7 +96,9 @@ public class CentralHealthReportLoopTests
collector, aggregator, clusterNodes, options,
NullLogger<CentralHealthReportLoop>.Instance);
await RunLoopBriefly(loop, 250);
// HealthMonitoring-022: wait up to 5 s for at least one report to fire
// rather than fixed-budget Task.Delay; tolerates slow CI runners.
await RunLoopUntil(loop, () => aggregator.Processed.Count >= 1);
Assert.NotEmpty(aggregator.Processed);
Assert.All(aggregator.Processed,
@@ -98,7 +140,10 @@ public class CentralHealthReportLoopTests
collector, aggregator, clusterNodes, options,
NullLogger<CentralHealthReportLoop>.Instance);
await RunLoopBriefly(loop, 300);
// HealthMonitoring-022: wait up to 5 s for at least 2 reports rather
// than a fixed 300 ms window that could miss the second tick on a
// slow CI runner; the assertion below proves the sequence is monotonic.
await RunLoopUntil(loop, () => aggregator.Processed.Count >= 2);
Assert.True(aggregator.Processed.Count >= 2,
$"Expected at least 2 reports, got {aggregator.Processed.Count}");
@@ -170,7 +215,10 @@ public class CentralHealthReportLoopTests
collector, aggregator, clusterNodes, options,
NullLogger<CentralHealthReportLoop>.Instance);
await RunLoopBriefly(loop, 450);
// HealthMonitoring-022: the first ProcessReport call throws (counters
// get restored), the second succeeds. Wait up to 5 s for that second
// (successful) call rather than a fixed 450 ms budget.
await RunLoopUntil(loop, () => aggregator.Processed.Count >= 1);
// First call threw, later succeeded — the first successful report
// must carry the previously-failed interval's accumulated counts.
@@ -0,0 +1,291 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using NSubstitute;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types.InboundApi;
using ScadaLink.InboundAPI.Middleware;
using System.Net;
using System.Text;
namespace ScadaLink.InboundAPI.Tests;
/// <summary>
/// InboundAPI-023: <see cref="EndpointExtensions.HandleInboundApiRequest"/> is
/// the composition wiring that ties validator → JSON parse → ParameterValidator →
/// InboundScriptExecutor → response shaping together. Each composed component
/// has its own unit tests, but the wiring itself was uncovered. These tests
/// drive the end-to-end POST /api/{methodName} flow through a TestServer so a
/// regression in any of the seams below would be caught here:
///
/// 1. happy path — 200 + script result body
/// 2. auth failures — validator status code propagates verbatim
/// 3. invalid JSON body — 400 + sanitized error
/// 4. parameter validation failure — 400 + ParameterValidator's error message
/// 5. script failure — 500 + ErrorMessage in body
/// 6. successful auth must publish the resolved API key name into
/// <c>HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey]</c> (so the
/// AuditWriteMiddleware sees a non-null Actor when it emits the audit row).
/// </summary>
public class EndpointExtensionsTests
{
/// <summary>
/// Stub hasher that returns its input unchanged. Same pattern as
/// <see cref="EndpointContentTypeTests"/> — lets us seed an ApiKey with a
/// known "hash" without depending on the configured HMAC pepper.
/// </summary>
private sealed class IdentityHasher : IApiKeyHasher
{
public string Hash(string keyValue) => keyValue;
}
/// <summary>
/// Inline middleware that captures the value at
/// <c>HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey]</c> after the
/// inbound endpoint runs, so the actor-stash invariant can be asserted from
/// the test without running the real AuditWriteMiddleware.
/// </summary>
private sealed class AuditActorCapture
{
public string? CapturedActor { get; set; }
}
private const string ApiKeyValue = "test-key";
private static ApiKey SeedKey(int id = 1, string name = "test")
{
var key = ApiKey.FromHash(name, ApiKeyValue);
key.IsEnabled = true;
key.Id = id;
return key;
}
private static ApiMethod SeedMethod(
int id, string name, string script, string? paramDefs = null)
{
return new ApiMethod(name, script)
{
Id = id,
TimeoutSeconds = 10,
ParameterDefinitions = paramDefs,
};
}
[Fact]
public async Task HappyPath_Returns200WithScriptResultJson()
{
var key = SeedKey();
var method = SeedMethod(1, "echo", "return Parameters[\"value\"];",
"""[{"name":"value","type":"Integer","required":true}]""");
using var host = await BuildHostAsync(key, method);
var client = host.GetTestClient();
var request = BuildPost("echo", """{"value":7}""");
var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains("7", body);
}
[Fact]
public async Task MissingApiKey_Returns401()
{
var key = SeedKey();
var method = SeedMethod(1, "noKey", "return 1;");
using var host = await BuildHostAsync(key, method);
var client = host.GetTestClient();
// No X-API-Key header — auth should reject with 401.
var request = new HttpRequestMessage(HttpMethod.Post, "/api/noKey")
{
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
};
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task UnknownMethod_Returns403_IndistinguishableFromNotApproved()
{
// InboundAPI-011: method existence is intentionally not observable —
// both "method not found" and "key not approved" surface as 403.
var key = SeedKey();
var method = SeedMethod(1, "knownMethod", "return 1;");
using var host = await BuildHostAsync(key, method);
var client = host.GetTestClient();
var request = BuildPost("unknownMethod", "{}");
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task InvalidJsonBody_Returns400()
{
var key = SeedKey();
var method = SeedMethod(1, "badJson", "return 1;");
using var host = await BuildHostAsync(key, method);
var client = host.GetTestClient();
var request = BuildPost("badJson", "{ not json");
var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("Invalid JSON", body);
}
[Fact]
public async Task MissingRequiredParameter_Returns400_FromParameterValidator()
{
var key = SeedKey();
var method = SeedMethod(1, "needsParam", "return Parameters[\"value\"];",
"""[{"name":"value","type":"Integer","required":true}]""");
using var host = await BuildHostAsync(key, method);
var client = host.GetTestClient();
// Body is empty object — required parameter "value" is missing.
var request = BuildPost("needsParam", "{}");
var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
// ParameterValidator's error message is surfaced.
Assert.Contains("value", body);
}
[Fact]
public async Task ScriptThrows_Returns500_WithSanitizedErrorBody()
{
var key = SeedKey();
// Throws inside the script body — InboundScriptExecutor catches the
// exception, logs it server-side, and surfaces the generic "Internal
// script error" message to the caller (the executor deliberately does
// not leak raw exception details — see InboundScriptExecutor.ExecuteAsync's
// catch block). The endpoint maps the script failure to HTTP 500.
var method = SeedMethod(1, "boom",
"""throw new System.InvalidOperationException("boom-msg");""");
using var host = await BuildHostAsync(key, method);
var client = host.GetTestClient();
var request = BuildPost("boom", "{}");
var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
// Wiring contract: error body is JSON-shaped and the raw exception
// message is not leaked (the executor sanitises before this point).
Assert.Contains("error", body);
Assert.DoesNotContain("boom-msg", body);
}
[Fact]
public async Task SuccessfulAuth_StashesResolvedApiKeyNameOnHttpContextItems()
{
// InboundAPI-023: the handler stashes the resolved API key's display name
// at HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey] AFTER auth
// succeeded, so AuditWriteMiddleware sees a populated Actor when it
// emits the audit row. A capture middleware reads the slot once the
// endpoint finishes, proving the wiring still publishes it.
var key = SeedKey(id: 99, name: "audit-actor-name");
var method = SeedMethod(1, "stamp", "return 1;");
var capture = new AuditActorCapture();
using var host = await BuildHostAsync(key, method, customize: builder =>
{
builder.Use(async (ctx, next) =>
{
await next();
if (ctx.Items.TryGetValue(
AuditWriteMiddleware.AuditActorItemKey, out var stashed)
&& stashed is string actorName)
{
capture.CapturedActor = actorName;
}
});
}, additionalServices: services =>
{
services.AddSingleton(capture);
});
var client = host.GetTestClient();
var request = BuildPost("stamp", "{}");
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("audit-actor-name", capture.CapturedActor);
}
private static HttpRequestMessage BuildPost(string methodName, string body)
{
var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
request.Headers.Add("X-API-Key", ApiKeyValue);
return request;
}
private static async Task<IHost> BuildHostAsync(
ApiKey key,
ApiMethod method,
Action<IApplicationBuilder>? customize = null,
Action<IServiceCollection>? additionalServices = null)
{
var repo = Substitute.For<IInboundApiRepository>();
repo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
.Returns(new List<ApiKey> { key });
repo.GetMethodByNameAsync(method.Name, Arg.Any<CancellationToken>())
.Returns(method);
repo.GetApprovedKeysForMethodAsync(method.Id, Arg.Any<CancellationToken>())
.Returns(new List<ApiKey> { key });
var hostBuilder = new HostBuilder()
.ConfigureWebHost(webBuilder =>
{
webBuilder
.UseTestServer()
.ConfigureServices(services =>
{
services.AddRouting();
services.AddSingleton(repo);
services.AddSingleton(Substitute.For<IInstanceLocator>());
services.Configure<InboundApiOptions>(_ => { });
services.AddInboundAPI();
// Replace the production CommunicationService-backed
// router and the configured HMAC hasher with test stubs
// (same pattern as EndpointContentTypeTests).
services.RemoveAll<IInstanceRouter>();
services.AddSingleton(Substitute.For<IInstanceRouter>());
services.RemoveAll<IApiKeyHasher>();
services.AddSingleton<IApiKeyHasher>(new IdentityHasher());
services.AddLogging();
additionalServices?.Invoke(services);
})
.Configure(app =>
{
app.UseRouting();
customize?.Invoke(app);
app.UseEndpoints(endpoints => endpoints.MapInboundAPI());
});
});
return await hostBuilder.StartAsync();
}
}
@@ -1157,4 +1157,263 @@ public class ManagementActorTests : TestKit, IDisposable
// The curated InstanceService failure message is still surfaced verbatim.
Assert.Contains("99", response.Error);
}
// ========================================================================
// ManagementService-021: Transport (#24) bundle handler coverage
//
// The three bundle handlers (HandleExportBundle / HandlePreviewBundle /
// HandleImportBundle) at ManagementActor.cs:1717-1897 previously had zero
// tests. The cases below pin the load-bearing behaviours: role gating,
// ExportBundle name resolution, ImportBundle blocker rejection, and the
// ImportBundle (EntityType, Name) dedupe.
// ========================================================================
/// <summary>
/// Adds a substituted <see cref="Commons.Interfaces.Transport.IBundleExporter"/> and
/// <see cref="Commons.Interfaces.Transport.IBundleImporter"/> to the test
/// service collection plus minimal repositories the bundle handlers query
/// from the export side. Returns both substitutes so a test can configure
/// their behaviour.
/// </summary>
private (Commons.Interfaces.Transport.IBundleExporter Exporter,
Commons.Interfaces.Transport.IBundleImporter Importer)
AddBundleSubstitutes()
{
var exporter = Substitute.For<Commons.Interfaces.Transport.IBundleExporter>();
var importer = Substitute.For<Commons.Interfaces.Transport.IBundleImporter>();
_services.AddSingleton(exporter);
_services.AddSingleton(importer);
// The repository fan-out HandleExportBundle does at the top of its body.
// Tests that only exercise role gating still need these resolved.
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Template>());
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Scripts.SharedScript>());
var externalRepo = Substitute.For<IExternalSystemRepository>();
externalRepo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.ExternalSystems.ExternalSystemDefinition>());
externalRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.ExternalSystems.DatabaseConnectionDefinition>());
_services.AddScoped(_ => externalRepo);
var notifRepo = Substitute.For<INotificationRepository>();
notifRepo.GetAllNotificationListsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.NotificationList>());
notifRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.SmtpConfiguration>());
_services.AddScoped(_ => notifRepo);
var inboundRepo = Substitute.For<IInboundApiRepository>();
inboundRepo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.InboundApi.ApiKey>());
inboundRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.InboundApi.ApiMethod>());
_services.AddScoped(_ => inboundRepo);
return (exporter, importer);
}
private static ExportBundleCommand AllExportCommand() =>
new(All: true,
TemplateNames: null, SharedScriptNames: null,
ExternalSystemNames: null, DatabaseConnectionNames: null,
NotificationListNames: null, SmtpConfigurationNames: null,
ApiKeyNames: null, ApiMethodNames: null,
IncludeDependencies: false, Passphrase: null,
SourceEnvironment: "test-env");
[Fact]
public void ExportBundleCommand_WithAdminRole_ReturnsUnauthorized()
{
// ExportBundle requires the Design role; an Admin-only caller is rejected.
AddBundleSubstitutes();
var actor = CreateActor();
var envelope = Envelope(AllExportCommand(), "Admin");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Design", response.Message);
}
[Fact]
public void PreviewBundleCommand_WithDesignRole_ReturnsUnauthorized()
{
// PreviewBundle requires the Admin role (Design role isn't enough,
// mirroring the Central UI gating — only Admin imports cross-cutting
// configuration).
AddBundleSubstitutes();
var actor = CreateActor();
var envelope = Envelope(new PreviewBundleCommand("AA==", null), "Design");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Admin", response.Message);
}
[Fact]
public void ImportBundleCommand_WithDesignRole_ReturnsUnauthorized()
{
AddBundleSubstitutes();
var actor = CreateActor();
var envelope = Envelope(new ImportBundleCommand("AA==", null, "skip"), "Design");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Admin", response.Message);
}
[Fact]
public void ExportBundleCommand_WithUnknownTemplateName_ReturnsManagementError()
{
// ResolveIds throws ManagementCommandException for unknown names; that
// curated message must surface verbatim to the caller.
AddBundleSubstitutes();
// No templates in the repo; the export selection names one anyway.
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Template>());
var cmd = new ExportBundleCommand(
All: false,
TemplateNames: new[] { "DoesNotExist" },
SharedScriptNames: null,
ExternalSystemNames: null, DatabaseConnectionNames: null,
NotificationListNames: null, SmtpConfigurationNames: null,
ApiKeyNames: null, ApiMethodNames: null,
IncludeDependencies: false, Passphrase: null,
SourceEnvironment: "test-env");
var actor = CreateActor();
var envelope = Envelope(cmd, "Design");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
// The ManagementCommandException message surfaces verbatim — it names
// the missing entity type and the missing name.
Assert.Contains("template", response.Error, StringComparison.OrdinalIgnoreCase);
Assert.Contains("DoesNotExist", response.Error);
}
[Fact]
public void ImportBundleCommand_WithBlockerRow_AbortsBeforeApply()
{
// A ConflictKind.Blocker in the preview must abort the import — the
// handler throws ManagementCommandException before calling ApplyAsync.
var (_, importer) = AddBundleSubstitutes();
var sessionId = Guid.NewGuid();
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.BundleSession
{
SessionId = sessionId,
Manifest = null!,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
});
var blockerItem = new Commons.Types.Transport.ImportPreviewItem(
EntityType: "Template",
Name: "BlockedTemplate",
ExistingVersion: null,
IncomingVersion: 1,
Kind: Commons.Types.Transport.ConflictKind.Blocker,
FieldDiffJson: null,
BlockerReason: "FK to missing site");
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.ImportPreview(
sessionId,
new[] { blockerItem }));
// A non-empty base64 payload that decodes — the handler does its own
// base64 check before reaching the importer.
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var actor = CreateActor();
var envelope = Envelope(new ImportBundleCommand(payload, null, "skip"), "Admin");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("blocker", response.Error, StringComparison.OrdinalIgnoreCase);
Assert.Contains("BlockedTemplate", response.Error);
// Apply must NOT have been called — the handler aborts before it.
importer.DidNotReceive().ApplyAsync(
Arg.Any<Guid>(),
Arg.Any<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
}
[Fact]
public void ImportBundleCommand_DuplicatePreviewItems_DedupePerEntityTypeAndName()
{
// The handler dedupes by (EntityType, Name) before calling ApplyAsync —
// last-write-wins, matching the Central UI's TransportImport behavior.
// The preview here emits THREE rows for the same (Template, "Dup"):
// an Identical then a Modified then an Identical. After dedupe, only
// one resolution must reach the importer for that key.
var (_, importer) = AddBundleSubstitutes();
var sessionId = Guid.NewGuid();
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.BundleSession
{
SessionId = sessionId,
Manifest = null!,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
});
Commons.Types.Transport.ImportPreviewItem Row(
Commons.Types.Transport.ConflictKind kind) =>
new("Template", "Dup", ExistingVersion: 1, IncomingVersion: 2,
Kind: kind, FieldDiffJson: null, BlockerReason: null);
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.ImportPreview(
sessionId,
new[]
{
Row(Commons.Types.Transport.ConflictKind.Identical),
Row(Commons.Types.Transport.ConflictKind.Modified),
Row(Commons.Types.Transport.ConflictKind.Identical),
}));
IReadOnlyList<Commons.Types.Transport.ImportResolution>? captured = null;
importer.ApplyAsync(
Arg.Any<Guid>(),
Arg.Do<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(
r => captured = r),
Arg.Any<string>(),
Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.ImportResult(
BundleImportId: Guid.NewGuid(),
Added: 0, Overwritten: 0, Skipped: 0, Renamed: 0,
StaleInstanceIds: Array.Empty<int>(),
AuditEventCorrelation: "correlation"));
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var actor = CreateActor();
// "overwrite" policy so the final (Identical) row would otherwise differ
// from the Modified row's action — proves the last-write-wins semantics.
var envelope = Envelope(new ImportBundleCommand(payload, null, "overwrite"), "Admin");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(captured);
// Only ONE resolution survives for the (Template, "Dup") key.
var dupResolutions = captured!
.Where(r => r.EntityType == "Template" && r.Name == "Dup")
.ToList();
Assert.Single(dupResolutions);
// Last-write-wins: the final Identical row's Skip action overrides the
// earlier Modified row's Overwrite action.
Assert.Equal(Commons.Types.Transport.ResolutionAction.Skip, dupResolutions[0].Action);
}
}
@@ -391,6 +391,121 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.Equal(3, allIds.Count);
}
[SkippableFact]
public async Task SiteCallQueryRequest_StuckOnly_CursorAtNonStuckBoundary_SkipsToNextStuckRow()
{
// SiteCallAudit-006 boundary regression. The earlier paging test
// interleaves stuck/non-stuck rows but the cursor between page-1 and
// page-2 always lands on a stuck row. This test forces the cursor to
// sit on a NON-stuck row (page-size=1 over a strict
// stuck-not_stuck-stuck-not_stuck-stuck-not_stuck pattern oldest-first)
// so the SQL-side composition of the stuck predicate AND the keyset
// cursor predicate (CreatedAtUtc < cursor OR =cursor AND id < ...) must
// honestly skip the non-stuck rows between each page. Each page must
// return exactly one stuck row, with no overlap and all three stuck
// rows visited across three pages.
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
var actor = CreateActor(repo, new SiteCallAuditOptions { StuckAgeThreshold = TimeSpan.FromMinutes(10) });
var now = DateTime.UtcNow;
var stuckIds = new List<Guid>();
// Insert pattern (relative-newest first, the order the actor returns
// them in DESC-by-CreatedAtUtc):
// t=-1m (non-stuck — Attempted but only 1m old, < 10m threshold),
// t=-15m (stuck — Attempted, 15m > 10m: stuckA),
// t=-20m (non-stuck — terminal Delivered between stuckA & stuckB),
// t=-25m (stuck — Attempted, 25m > 10m: stuckB),
// t=-30m (non-stuck — terminal Delivered between stuckB & stuckC),
// t=-35m (stuck — Attempted, 35m > 10m: stuckC),
// t=-40m (non-stuck — terminal Delivered, oldest of all).
// The non-stuck rows at -20m and -30m sit between consecutive stuck
// rows, so the page-size-1 cursor lands ON a non-stuck row between
// pages — exactly the boundary the predicate composition must skip.
var stuckA = TrackedOperationId.New();
var stuckB = TrackedOperationId.New();
var stuckC = TrackedOperationId.New();
// Expected DESC-by-CreatedAtUtc order: A (-15m newest), B (-25m), C (-35m oldest).
stuckIds.Add(stuckA.Value);
stuckIds.Add(stuckB.Value);
stuckIds.Add(stuckC.Value);
await repo.UpsertAsync(NewRow(
TrackedOperationId.New(), siteId, status: "Delivered",
createdAtUtc: now.AddMinutes(-40), terminal: true));
await repo.UpsertAsync(NewRow(
stuckC, siteId, status: "Attempted",
createdAtUtc: now.AddMinutes(-35)));
await repo.UpsertAsync(NewRow(
TrackedOperationId.New(), siteId, status: "Delivered",
createdAtUtc: now.AddMinutes(-30), terminal: true));
await repo.UpsertAsync(NewRow(
stuckB, siteId, status: "Attempted",
createdAtUtc: now.AddMinutes(-25)));
await repo.UpsertAsync(NewRow(
TrackedOperationId.New(), siteId, status: "Delivered",
createdAtUtc: now.AddMinutes(-20), terminal: true));
await repo.UpsertAsync(NewRow(
stuckA, siteId, status: "Attempted",
createdAtUtc: now.AddMinutes(-15)));
await repo.UpsertAsync(NewRow(
TrackedOperationId.New(), siteId, status: "Attempted",
createdAtUtc: now.AddMinutes(-1)));
// Page-1: page-size=1, expect stuckA (newest stuck row).
actor.Tell(
new SiteCallQueryRequest(
"corr-stuck-b1", null, siteId, null, null, StuckOnly: true,
null, null, null, null, PageSize: 1),
TestActor);
var page1 = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
Assert.True(page1.Success);
Assert.Single(page1.SiteCalls);
Assert.True(page1.SiteCalls[0].IsStuck);
Assert.Equal(stuckA.Value, page1.SiteCalls[0].TrackedOperationId);
Assert.NotNull(page1.NextAfterCreatedAtUtc);
// Page-2: between stuckA and stuckB the non-stuck terminal row at -20m
// sits at the cursor — the SQL must skip it, NOT return it.
actor.Tell(
new SiteCallQueryRequest(
"corr-stuck-b2", null, siteId, null, null, StuckOnly: true,
null, null, page1.NextAfterCreatedAtUtc, page1.NextAfterId,
PageSize: 1),
TestActor);
var page2 = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
Assert.True(page2.Success);
Assert.Single(page2.SiteCalls);
Assert.True(page2.SiteCalls[0].IsStuck);
Assert.Equal(stuckB.Value, page2.SiteCalls[0].TrackedOperationId);
Assert.NotNull(page2.NextAfterCreatedAtUtc);
// Page-3: between stuckB and stuckC the non-stuck row at -30m sits at
// the cursor — again must be skipped.
actor.Tell(
new SiteCallQueryRequest(
"corr-stuck-b3", null, siteId, null, null, StuckOnly: true,
null, null, page2.NextAfterCreatedAtUtc, page2.NextAfterId,
PageSize: 1),
TestActor);
var page3 = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
Assert.True(page3.Success);
Assert.Single(page3.SiteCalls);
Assert.True(page3.SiteCalls[0].IsStuck);
Assert.Equal(stuckC.Value, page3.SiteCalls[0].TrackedOperationId);
// All three stuck rows visited exactly once, in DESC order, with no
// non-stuck rows leaking through despite the cursor sitting on them.
var visited = new[] { page1, page2, page3 }
.SelectMany(p => p.SiteCalls)
.Select(s => s.TrackedOperationId)
.ToList();
Assert.Equal(stuckIds, visited);
}
[SkippableFact]
public async Task SiteCallDetailRequest_KnownId_ReturnsFullDetail()
{
@@ -9,6 +9,15 @@ public class EventLogPurgeServiceTests : IDisposable
private readonly string _dbPath;
private readonly SiteEventLogOptions _options;
/// <summary>
/// SiteEventLogging-023: stop flag for the concurrent stress test. Declared as
/// a <c>volatile</c> field so every writer thread observes the main thread's
/// `_stop = true` write without depending on JIT/runtime quirks. A plain
/// <c>bool</c> local would be legal-cached in a register inside the tight
/// <c>while (!_stop)</c> loop under release-mode optimisation.
/// </summary>
private volatile bool _stop;
public EventLogPurgeServiceTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"test_purge_{Guid.NewGuid()}.db");
@@ -282,7 +291,13 @@ public class EventLogPurgeServiceTests : IDisposable
};
var exceptions = new System.Collections.Concurrent.ConcurrentBag<Exception>();
var stop = false;
// SiteEventLogging-023: must be volatile so the writer threads observe the
// main thread's `stop = true` flip after the purge task completes. Without
// it, a release-mode JIT is permitted to cache the `stop = false` read in
// a register inside the tight `while (!stop)` loop and never see the flip,
// causing the writer tasks to hang past xUnit's per-test timeout instead
// of asserting `Empty(exceptions)`.
_stop = false;
var purgeTask = Task.Run(() =>
{
@@ -298,7 +313,7 @@ public class EventLogPurgeServiceTests : IDisposable
{
try
{
while (!stop)
while (!_stop)
{
await _eventLogger.LogEventAsync("script", "Info", null, "Concurrent", "Concurrent write");
}
@@ -307,7 +322,7 @@ public class EventLogPurgeServiceTests : IDisposable
})).ToArray();
await purgeTask;
stop = true;
_stop = true;
await Task.WhenAll(writeTasks);
Assert.Empty(exceptions);