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:
@@ -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
|
||||
|
||||
| | |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
| | |
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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._
|
||||
|
||||
@@ -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;`
|
||||
|
||||
@@ -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
|
||||
|
||||
| | |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+134
@@ -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&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);
|
||||
|
||||
Reference in New Issue
Block a user