Compare commits
6 Commits
d8f99ba781
...
d7630d80fe
| Author | SHA1 | Date | |
|---|---|---|---|
| d7630d80fe | |||
| db08c6eb38 | |||
| 9043f0089b | |||
| 301e7fb854 | |||
| 87f14c190a | |||
| 5a08b04535 |
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 13 |
|
| Open findings | 12 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ argument parsing, and the command-tree wiring are untested.
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.CLI/Commands/CommandHelpers.cs:18`, `src/ScadaLink.CLI/Commands/DebugCommands.cs:45`, `src/ScadaLink.CLI/CliConfig.cs:37-39` |
|
| Location | `src/ScadaLink.CLI/Commands/CommandHelpers.cs:18`, `src/ScadaLink.CLI/Commands/DebugCommands.cs:45`, `src/ScadaLink.CLI/CliConfig.cs:37-39` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -79,7 +79,12 @@ only then override the config value. Apply the same fix to `DebugCommands.BuildS
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `<pending>`). Removed the `--format` option's
|
||||||
|
`DefaultValueFactory` in `Program.cs` and added `CommandHelpers.ResolveFormat`, which uses
|
||||||
|
`ParseResult.GetResult(formatOption)` to detect an explicitly supplied flag and resolves
|
||||||
|
precedence explicitly: explicit `--format` → `CliConfig.DefaultFormat` (env var / config
|
||||||
|
file) → `"json"`. Both `CommandHelpers.ExecuteCommandAsync` and `DebugCommands.BuildStream`
|
||||||
|
now call `ResolveFormat`. Regression tests added in `FormatResolutionTests`.
|
||||||
|
|
||||||
### CLI-002 — Empty success body crashes table rendering with an unhandled exception
|
### CLI-002 — Empty success body crashes table rendering with an unhandled exception
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 18 |
|
| Open findings | 15 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ the commit whose message references `CentralUI-001`.
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Security |
|
| Category | Security |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs:63-69`; `src/ScadaLink.CentralUI/Components/Pages/Deployment/*.razor` |
|
| Location | `src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs:63-69`; `src/ScadaLink.CentralUI/Components/Pages/Deployment/*.razor` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -134,7 +134,20 @@ keep this consistent.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16. Confirmed: the `SiteId` claim was written at login
|
||||||
|
(`AuthEndpoints`, `RoleMapper`) but never read by any CentralUI page — site
|
||||||
|
scoping was unenforced. Added a scoped `SiteScopeService` (`Auth/SiteScopeService.cs`)
|
||||||
|
that reads the current circuit's `SiteId` claims and exposes `IsSystemWideAsync`,
|
||||||
|
`PermittedSiteIdsAsync`, `FilterSitesAsync`, and `IsSiteAllowedAsync` (absence of
|
||||||
|
claims = system-wide, matching `SiteScopeAuthorizationHandler`). All seven
|
||||||
|
Deployment/Monitoring pages now consume it: `Topology`, `DebugView`,
|
||||||
|
`InstanceCreate`, `Deployments` filter their site/instance lists; `InstanceConfigure`
|
||||||
|
rejects direct navigation to an instance on a non-permitted site; `DebugView`,
|
||||||
|
`InstanceCreate`, and `ParkedMessages` re-check the claim server-side before any
|
||||||
|
mutating/streaming command. Regression tests: `SiteScopeServiceTests` (6 tests
|
||||||
|
pinning the helper logic) and `TopologyPageTests.SiteScoping_ScopedDeploymentUser_OnlySeesPermittedSites`
|
||||||
|
/ `SiteScoping_SystemWideDeploymentUser_SeesAllSites`. Fixed by the commit whose
|
||||||
|
message references `CentralUI-002`.
|
||||||
|
|
||||||
### CentralUI-003 — `Console.SetOut`/`SetError` mutates process-global state across concurrent circuits
|
### CentralUI-003 — `Console.SetOut`/`SetError` mutates process-global state across concurrent circuits
|
||||||
|
|
||||||
@@ -142,7 +155,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs:359-423` |
|
| Location | `src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs:359-423` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -166,7 +179,20 @@ the correct fix.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16. Confirmed: `RunInSandboxAsync` redirected the process-global
|
||||||
|
`Console.Out`/`Console.Error` per call and restored them in `finally`, so a
|
||||||
|
concurrent run's `finally` could restore the writer while another run was still
|
||||||
|
executing — the long run silently lost output (reproduced by the regression
|
||||||
|
test, 74 of 80 expected lines captured). Added `SandboxConsoleCapture`, a routing
|
||||||
|
`TextWriter` installed into `Console.Out`/`Console.Error` exactly once for the
|
||||||
|
process; each run pushes its own `StringWriter` onto an `AsyncLocal` capture
|
||||||
|
scope via `BeginCapture`, so writes are routed per logical call-tree with no
|
||||||
|
per-run mutation of global `Console` state. `RunInSandboxAsync` now opens the
|
||||||
|
scope with `using` declarations instead of calling `Console.SetOut`. Regression
|
||||||
|
tests `RunInSandbox_CapturesConsoleOutput` and
|
||||||
|
`RunInSandbox_ConcurrentRuns_DoNotCrossContaminateConsoleOutput` fail against the
|
||||||
|
pre-fix code and pass after. Fixed by the commit whose message references
|
||||||
|
`CentralUI-003`.
|
||||||
|
|
||||||
### CentralUI-004 — `CookieAuthenticationStateProvider` reads `HttpContext` for the life of the circuit
|
### CentralUI-004 — `CookieAuthenticationStateProvider` reads `HttpContext` for the life of the circuit
|
||||||
|
|
||||||
@@ -174,7 +200,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Security |
|
| Category | Security |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.CentralUI/Auth/CookieAuthenticationStateProvider.cs:22-28` |
|
| Location | `src/ScadaLink.CentralUI/Auth/CookieAuthenticationStateProvider.cs:22-28` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -202,7 +228,19 @@ circuit is established.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16. Confirmed: `GetAuthenticationStateAsync` read
|
||||||
|
`_httpContextAccessor.HttpContext?.User` on every call; the provider is
|
||||||
|
registered `Scoped`, so it is constructed within the initial HTTP request's DI
|
||||||
|
scope while `HttpContext` is still valid, but every later call (an
|
||||||
|
`<AuthorizeView>` re-evaluating, or a page calling it directly) over the
|
||||||
|
long-lived SignalR circuit saw `HttpContext == null` and returned an anonymous
|
||||||
|
principal. The provider now snapshots the principal once in the constructor into
|
||||||
|
a cached `Task<AuthenticationState>` and serves that for the life of the
|
||||||
|
circuit, never touching `IHttpContextAccessor` again. Regression tests
|
||||||
|
`CookieAuthenticationStateProviderTests.GetAuthenticationStateAsync_StillReturnsUser_AfterHttpContextIsGone`
|
||||||
|
and `..._IsStableAcrossCalls_IgnoringStaleForeignContext` fail against the
|
||||||
|
pre-fix code (they would see an anonymous / foreign principal) and pass after.
|
||||||
|
Fixed by the commit whose message references `CentralUI-004`.
|
||||||
|
|
||||||
### CentralUI-005 — Session expiry implementation diverges from the documented policy
|
### CentralUI-005 — Session expiry implementation diverges from the documented policy
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,49 @@ should clearly state it is unimplemented so callers do not assume otherwise.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
_Re-triaged 2026-05-16 — remains Open, needs a design decision from the user._
|
||||||
|
|
||||||
|
Verified against the source at the reviewed commit: the finding's factual claims hold.
|
||||||
|
`src/ScadaLink.ClusterInfrastructure` still contains only `ClusterOptions.cs` and a
|
||||||
|
no-op `ServiceCollectionExtensions.cs`, and the `.csproj` references no Akka packages.
|
||||||
|
|
||||||
|
However, the documented cluster behaviour is **not actually absent from the system** —
|
||||||
|
it has been implemented in the **Host** project rather than in this module:
|
||||||
|
|
||||||
|
- `src/ScadaLink.Host/Actors/AkkaHostedService.cs` bootstraps the `ActorSystem`,
|
||||||
|
generates the HOCON from `ClusterOptions` (it imports `ScadaLink.ClusterInfrastructure`
|
||||||
|
and injects `IOptions<ClusterOptions>`), and configures the `keep-oldest` split-brain
|
||||||
|
resolver with `down-if-alone = on` (see `AkkaHostedService.cs:95-96`).
|
||||||
|
- `src/ScadaLink.Host/Health/AkkaClusterHealthCheck.cs`, `AkkaClusterNodeProvider.cs`,
|
||||||
|
and `Health/ActiveNodeHealthCheck.cs` cover cluster membership / active-node detection.
|
||||||
|
- Akka cluster/remote package references live in `ScadaLink.Host.csproj` and the
|
||||||
|
per-component projects (`SiteRuntime`, `Communication`, etc.).
|
||||||
|
|
||||||
|
So the real situation is an **ownership / design-doc drift**, not missing behaviour:
|
||||||
|
`Component-ClusterInfrastructure.md` assigns the Akka bootstrap, HOCON generation,
|
||||||
|
split-brain config and `CoordinatedShutdown` wiring to this module, but the
|
||||||
|
implementation deliberately lives in the Host. `ClusterOptions` is the one piece this
|
||||||
|
module legitimately owns and it is consumed correctly by the Host.
|
||||||
|
|
||||||
|
Resolving CI-001 as literally written is **not a small, well-scoped fix** — it is one
|
||||||
|
of two substantial decisions, both requiring the user:
|
||||||
|
|
||||||
|
1. **Move the bootstrap into this module** — relocate the HOCON generation, split-brain
|
||||||
|
config, cluster-singleton helpers and `CoordinatedShutdown` wiring out of
|
||||||
|
`ScadaLink.Host` into `ScadaLink.ClusterInfrastructure`, add the Akka package
|
||||||
|
references, and re-wire the Host to call into it. This is a cross-module refactor
|
||||||
|
touching `src/ScadaLink.Host/*` and several other projects — outside the edit scope
|
||||||
|
permitted for this finding (only `src/ScadaLink.ClusterInfrastructure/`,
|
||||||
|
`tests/ScadaLink.ClusterInfrastructure.Tests/`, and this file may be edited).
|
||||||
|
2. **Accept the current placement** — keep the bootstrap in the Host and update
|
||||||
|
`Component-ClusterInfrastructure.md` (and the README component table) to record that
|
||||||
|
the Host owns the actor-system/cluster bootstrap and that this module's role is the
|
||||||
|
shared `ClusterOptions` contract. That fix is a design-doc edit, also outside this
|
||||||
|
module's permitted edit scope.
|
||||||
|
|
||||||
|
Either path is a deliberate architecture decision, not a bug fix, so per
|
||||||
|
REVIEW-PROCESS.md §2 this finding is left **Open** and surfaced for the user to decide.
|
||||||
|
No code change was made. Module test suite verified green (3 passed) at re-triage time.
|
||||||
|
|
||||||
### ClusterInfrastructure-002 — No-op DI extension methods report success while doing nothing
|
### ClusterInfrastructure-002 — No-op DI extension methods report success while doing nothing
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 10 |
|
| Open findings | 8 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ references `Communication-001`.
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Error handling & resilience |
|
| Category | Error handling & resilience |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.Communication/Actors/DebugStreamBridgeActor.cs:170`, `src/ScadaLink.Communication/Actors/DebugStreamBridgeActor.cs:143` |
|
| Location | `src/ScadaLink.Communication/Actors/DebugStreamBridgeActor.cs:170`, `src/ScadaLink.Communication/Actors/DebugStreamBridgeActor.cs:143` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -126,7 +126,14 @@ the gRPC cancellation reaches the site and stops the relay actor.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `<pending>`). Root cause confirmed against source:
|
||||||
|
`HandleGrpcError` flipped `_useNodeA` and scheduled `OpenGrpcStream` without ever
|
||||||
|
unsubscribing the failed stream, leaving the old node's `StreamRelayActor` zombie until
|
||||||
|
TCP/keepalive timeout. Fix: `HandleGrpcError` now resolves the client for the
|
||||||
|
*previous* endpoint (before flipping `_useNodeA`) and calls `Unsubscribe(_correlationId)`
|
||||||
|
on it, so the local CTS is cancelled and gRPC cancellation reaches the still-alive site.
|
||||||
|
Regression test `DebugStreamBridgeActorTests.On_GrpcError_Unsubscribes_Old_Stream_Before_Reconnect`
|
||||||
|
fails against the pre-fix code and passes after.
|
||||||
|
|
||||||
### Communication-003 — SiteStreamGrpcClient subscription map overwritten without disposal; reconnect can cancel the wrong stream
|
### Communication-003 — SiteStreamGrpcClient subscription map overwritten without disposal; reconnect can cancel the wrong stream
|
||||||
|
|
||||||
@@ -134,7 +141,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.Communication/Grpc/SiteStreamGrpcClient.cs:77`, `src/ScadaLink.Communication/Grpc/SiteStreamGrpcClient.cs:106` |
|
| Location | `src/ScadaLink.Communication/Grpc/SiteStreamGrpcClient.cs:77`, `src/ScadaLink.Communication/Grpc/SiteStreamGrpcClient.cs:106` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -161,7 +168,18 @@ caller-supplied correlation ID.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `<pending>`). Root cause confirmed against source: the
|
||||||
|
inline `_subscriptions[correlationId] = cts` overwrote a prior CTS without
|
||||||
|
cancel/dispose (leak), and the `finally`'s `TryRemove(correlationId, out _)` removed by
|
||||||
|
key only — a racing reconnect's live CTS could be removed by the prior call's `finally`,
|
||||||
|
orphaning the live stream. Fix: extracted two internal helpers used by `SubscribeAsync`
|
||||||
|
— `RegisterSubscription` cancels+disposes any existing CTS for the correlation ID before
|
||||||
|
inserting, and `RemoveSubscription` uses the `ConcurrentDictionary.TryRemove(KeyValuePair)`
|
||||||
|
overload so it removes only the CTS that call created (mirroring `SiteStreamGrpcServer`'s
|
||||||
|
`StreamEntry` pattern). Regression tests
|
||||||
|
`SiteStreamGrpcClientTests.RegisterSubscription_ReusedCorrelationId_CancelsAndDisposesPriorCts`
|
||||||
|
and `SiteStreamGrpcClientTests.RemoveSubscription_OnlyRemovesOwnCts_NotAReplacement`
|
||||||
|
fail against the pre-fix logic and pass after.
|
||||||
|
|
||||||
### Communication-004 — Coordinator actors declare no SupervisorStrategy (design requires Resume)
|
### Communication-004 — Coordinator actors declare no SupervisorStrategy (design requires Resume)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 11 |
|
| Open findings | 10 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ repositories (`TemplateEngineRepository`, `DeploymentManagerRepository`,
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs:30-41` |
|
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs:30-41` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -84,7 +84,28 @@ explicit result tuple/DTO so the loaded data reaches the caller.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `<pending>`). Root cause confirmed against source: the
|
||||||
|
method ran a `Where(t => t.ParentTemplateId == id)` query, assigned the result to a
|
||||||
|
local `children` variable, and never used it — a misleading no-op that also issued an
|
||||||
|
extra database round-trip per call.
|
||||||
|
|
||||||
|
Triage of the three callers (`FlatteningPipeline.BuildTemplateChainAsync`,
|
||||||
|
`ManagementActor.HandleGetTemplate`, `ManagementActor.HandleValidateTemplate`) showed
|
||||||
|
none consume derived/sub-templates; they all need the template's *member* collections
|
||||||
|
(Attributes/Alarms/Scripts/Compositions), which `GetTemplateByIdAsync` already
|
||||||
|
eager-loads. The `Template` entity has no child-templates navigation collection, and
|
||||||
|
adding one (plus changing the interface signature) would require editing
|
||||||
|
`ScadaLink.Commons`, which is outside this module's scope.
|
||||||
|
|
||||||
|
Fix applied the recommendation's secondary option: removed the dead query so the
|
||||||
|
method no longer misleads or wastes a round-trip, and added an XML doc comment
|
||||||
|
clarifying that "children" means the template's member collections. The method now
|
||||||
|
honestly delegates to `GetTemplateByIdAsync`. Regression tests added in
|
||||||
|
`TemplateEngineRepositoryTests.cs`:
|
||||||
|
`GetTemplateWithChildrenAsync_ReturnsTemplateWithAllMemberCollectionsPopulated`,
|
||||||
|
`GetTemplateWithChildrenAsync_PreservesParentTemplateId_ForInheritanceChainWalk`, and
|
||||||
|
`GetTemplateWithChildrenAsync_ReturnsNull_WhenTemplateDoesNotExist` — pinning the
|
||||||
|
template-aggregate contract the callers depend on.
|
||||||
|
|
||||||
### ConfigurationDatabase-002 — Hardcoded `sa` connection string with embedded password literal
|
### ConfigurationDatabase-002 — Hardcoded `sa` connection string with embedded password literal
|
||||||
|
|
||||||
|
|||||||
+7
-14
@@ -40,21 +40,21 @@ module file and counted in **Total**.
|
|||||||
| Severity | Open findings |
|
| Severity | Open findings |
|
||||||
|----------|---------------|
|
|----------|---------------|
|
||||||
| Critical | 0 |
|
| Critical | 0 |
|
||||||
| High | 46 |
|
| High | 39 |
|
||||||
| Medium | 100 |
|
| Medium | 100 |
|
||||||
| Low | 89 |
|
| Low | 89 |
|
||||||
| **Total** | **235** |
|
| **Total** | **228** |
|
||||||
|
|
||||||
## Module Status
|
## Module Status
|
||||||
|
|
||||||
| Module | Last reviewed | Commit | Open (C/H/M/L) | Open | Total |
|
| Module | Last reviewed | Commit | Open (C/H/M/L) | Open | Total |
|
||||||
|--------|---------------|--------|----------------|------|-------|
|
|--------|---------------|--------|----------------|------|-------|
|
||||||
| [CLI](CLI/findings.md) | 2026-05-16 | `9c60592` | 0/1/6/6 | 13 | 13 |
|
| [CLI](CLI/findings.md) | 2026-05-16 | `9c60592` | 0/0/6/6 | 12 | 13 |
|
||||||
| [CentralUI](CentralUI/findings.md) | 2026-05-16 | `9c60592` | 0/3/10/5 | 18 | 19 |
|
| [CentralUI](CentralUI/findings.md) | 2026-05-16 | `9c60592` | 0/0/10/5 | 15 | 19 |
|
||||||
| [ClusterInfrastructure](ClusterInfrastructure/findings.md) | 2026-05-16 | `9c60592` | 0/1/4/3 | 8 | 8 |
|
| [ClusterInfrastructure](ClusterInfrastructure/findings.md) | 2026-05-16 | `9c60592` | 0/1/4/3 | 8 | 8 |
|
||||||
| [Commons](Commons/findings.md) | 2026-05-16 | `9c60592` | 0/0/4/8 | 12 | 12 |
|
| [Commons](Commons/findings.md) | 2026-05-16 | `9c60592` | 0/0/4/8 | 12 | 12 |
|
||||||
| [Communication](Communication/findings.md) | 2026-05-16 | `9c60592` | 0/2/5/3 | 10 | 11 |
|
| [Communication](Communication/findings.md) | 2026-05-16 | `9c60592` | 0/0/5/3 | 8 | 11 |
|
||||||
| [ConfigurationDatabase](ConfigurationDatabase/findings.md) | 2026-05-16 | `9c60592` | 0/1/4/6 | 11 | 11 |
|
| [ConfigurationDatabase](ConfigurationDatabase/findings.md) | 2026-05-16 | `9c60592` | 0/0/4/6 | 10 | 11 |
|
||||||
| [DataConnectionLayer](DataConnectionLayer/findings.md) | 2026-05-16 | `9c60592` | 0/4/6/2 | 12 | 13 |
|
| [DataConnectionLayer](DataConnectionLayer/findings.md) | 2026-05-16 | `9c60592` | 0/4/6/2 | 12 | 13 |
|
||||||
| [DeploymentManager](DeploymentManager/findings.md) | 2026-05-16 | `9c60592` | 0/3/6/5 | 14 | 14 |
|
| [DeploymentManager](DeploymentManager/findings.md) | 2026-05-16 | `9c60592` | 0/3/6/5 | 14 | 14 |
|
||||||
| [ExternalSystemGateway](ExternalSystemGateway/findings.md) | 2026-05-16 | `9c60592` | 0/2/7/4 | 13 | 14 |
|
| [ExternalSystemGateway](ExternalSystemGateway/findings.md) | 2026-05-16 | `9c60592` | 0/2/7/4 | 13 | 14 |
|
||||||
@@ -80,18 +80,11 @@ description, location, recommendation — lives in the module's `findings.md`.
|
|||||||
|
|
||||||
_None open._
|
_None open._
|
||||||
|
|
||||||
### High (46)
|
### High (39)
|
||||||
|
|
||||||
| ID | Module | Title |
|
| ID | Module | Title |
|
||||||
|----|--------|-------|
|
|----|--------|-------|
|
||||||
| CLI-001 | [CLI](CLI/findings.md) | `SCADALINK_FORMAT` env var and config-file format are dead; format precedence broken |
|
|
||||||
| CentralUI-002 | [CentralUI](CentralUI/findings.md) | Site-scoped Deployment permissions are issued but never enforced |
|
|
||||||
| CentralUI-003 | [CentralUI](CentralUI/findings.md) | `Console.SetOut`/`SetError` mutates process-global state across concurrent circuits |
|
|
||||||
| CentralUI-004 | [CentralUI](CentralUI/findings.md) | `CookieAuthenticationStateProvider` reads `HttpContext` for the life of the circuit |
|
|
||||||
| ClusterInfrastructure-001 | [ClusterInfrastructure](ClusterInfrastructure/findings.md) | Module implements none of its documented responsibilities |
|
| ClusterInfrastructure-001 | [ClusterInfrastructure](ClusterInfrastructure/findings.md) | Module implements none of its documented responsibilities |
|
||||||
| Communication-002 | [Communication](Communication/findings.md) | gRPC reconnect does not unsubscribe the previous stream, leaking site-side relay actors |
|
|
||||||
| Communication-003 | [Communication](Communication/findings.md) | SiteStreamGrpcClient subscription map overwritten without disposal; reconnect can cancel the wrong stream |
|
|
||||||
| ConfigurationDatabase-001 | [ConfigurationDatabase](ConfigurationDatabase/findings.md) | `GetTemplateWithChildrenAsync` loads child templates then discards them |
|
|
||||||
| DataConnectionLayer-002 | [DataConnectionLayer](DataConnectionLayer/findings.md) | `Restart` supervision discards all subscription state on connection-actor crash |
|
| DataConnectionLayer-002 | [DataConnectionLayer](DataConnectionLayer/findings.md) | `Restart` supervision discards all subscription state on connection-actor crash |
|
||||||
| DataConnectionLayer-003 | [DataConnectionLayer](DataConnectionLayer/findings.md) | `RealOpcUaClient` callback/monitored-item dictionaries mutated without synchronization |
|
| DataConnectionLayer-003 | [DataConnectionLayer](DataConnectionLayer/findings.md) | `RealOpcUaClient` callback/monitored-item dictionaries mutated without synchronization |
|
||||||
| DataConnectionLayer-004 | [DataConnectionLayer](DataConnectionLayer/findings.md) | Subscribe-time tag-resolution failure leaves the connection healthy but never recovers correctly |
|
| DataConnectionLayer-004 | [DataConnectionLayer](DataConnectionLayer/findings.md) | Subscribe-time tag-resolution failure leaves the connection healthy but never recovers correctly |
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ internal static class CommandHelpers
|
|||||||
Option<string> passwordOption,
|
Option<string> passwordOption,
|
||||||
object command)
|
object command)
|
||||||
{
|
{
|
||||||
var format = result.GetValue(formatOption) ?? "json";
|
|
||||||
var config = CliConfig.Load();
|
var config = CliConfig.Load();
|
||||||
|
var format = ResolveFormat(result, formatOption, config);
|
||||||
|
|
||||||
// Resolve management URL
|
// Resolve management URL
|
||||||
var url = result.GetValue(urlOption);
|
var url = result.GetValue(urlOption);
|
||||||
@@ -53,6 +53,27 @@ internal static class CommandHelpers
|
|||||||
return HandleResponse(response, format);
|
return HandleResponse(response, format);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the output format using the documented precedence chain:
|
||||||
|
/// an explicitly supplied <c>--format</c> option wins, otherwise the
|
||||||
|
/// config-file / environment-variable default (<see cref="CliConfig.DefaultFormat"/>)
|
||||||
|
/// is used, otherwise <c>json</c>. The <c>--format</c> option must not declare a
|
||||||
|
/// <c>DefaultValueFactory</c> — that would mask whether the flag was supplied.
|
||||||
|
/// </summary>
|
||||||
|
internal static string ResolveFormat(ParseResult result, Option<string> formatOption, CliConfig config)
|
||||||
|
{
|
||||||
|
// GetResult returns non-null only when the option was actually present on the
|
||||||
|
// command line, letting an explicit --format override the config default.
|
||||||
|
if (result.GetResult(formatOption) != null)
|
||||||
|
{
|
||||||
|
var explicitValue = result.GetValue(formatOption);
|
||||||
|
if (!string.IsNullOrWhiteSpace(explicitValue))
|
||||||
|
return explicitValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(config.DefaultFormat) ? "json" : config.DefaultFormat;
|
||||||
|
}
|
||||||
|
|
||||||
internal static int HandleResponse(ManagementResponse response, string format)
|
internal static int HandleResponse(ManagementResponse response, string format)
|
||||||
{
|
{
|
||||||
if (response.JsonData != null)
|
if (response.JsonData != null)
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ public static class DebugCommands
|
|||||||
cmd.SetAction(async (ParseResult result) =>
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
var instanceId = result.GetValue(idOption);
|
var instanceId = result.GetValue(idOption);
|
||||||
var format = result.GetValue(formatOption) ?? "json";
|
|
||||||
var config = CliConfig.Load();
|
var config = CliConfig.Load();
|
||||||
|
var format = CommandHelpers.ResolveFormat(result, formatOption, config);
|
||||||
|
|
||||||
var url = result.GetValue(urlOption);
|
var url = result.GetValue(urlOption);
|
||||||
if (string.IsNullOrWhiteSpace(url))
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ var rootCommand = new RootCommand("ScadaLink CLI — manage the ScadaLink SCADA
|
|||||||
var urlOption = new Option<string>("--url") { Description = "Management API URL", Recursive = true };
|
var urlOption = new Option<string>("--url") { Description = "Management API URL", Recursive = true };
|
||||||
var usernameOption = new Option<string>("--username") { Description = "LDAP username", Recursive = true };
|
var usernameOption = new Option<string>("--username") { Description = "LDAP username", Recursive = true };
|
||||||
var passwordOption = new Option<string>("--password") { Description = "LDAP password", Recursive = true };
|
var passwordOption = new Option<string>("--password") { Description = "LDAP password", Recursive = true };
|
||||||
|
// No DefaultValueFactory: format precedence (explicit --format -> config/env -> "json")
|
||||||
|
// is resolved by CommandHelpers.ResolveFormat, which needs to distinguish an absent flag.
|
||||||
var formatOption = new Option<string>("--format") { Description = "Output format (json or table)", Recursive = true };
|
var formatOption = new Option<string>("--format") { Description = "Output format (json or table)", Recursive = true };
|
||||||
formatOption.DefaultValueFactory = _ => "json";
|
|
||||||
|
|
||||||
rootCommand.Add(urlOption);
|
rootCommand.Add(urlOption);
|
||||||
rootCommand.Add(usernameOption);
|
rootCommand.Add(usernameOption);
|
||||||
|
|||||||
@@ -7,23 +7,37 @@ namespace ScadaLink.CentralUI.Auth;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bridges ASP.NET Core cookie authentication with Blazor Server's auth state.
|
/// Bridges ASP.NET Core cookie authentication with Blazor Server's auth state.
|
||||||
/// The cookie middleware has already validated and decrypted the cookie by the time
|
/// <para>
|
||||||
/// the Blazor circuit is established, so we just read HttpContext.User.
|
/// The cookie middleware validates and decrypts the cookie during the initial
|
||||||
|
/// HTTP request that establishes the Blazor circuit. This provider is registered
|
||||||
|
/// <c>Scoped</c>, so it is constructed within that request's DI scope while
|
||||||
|
/// <see cref="IHttpContextAccessor.HttpContext"/> is still valid. We snapshot
|
||||||
|
/// the authenticated principal <b>once</b> in the constructor and serve that
|
||||||
|
/// snapshot for the lifetime of the circuit.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// We must NOT read <see cref="IHttpContextAccessor"/> on every
|
||||||
|
/// <see cref="GetAuthenticationStateAsync"/> call (CentralUI-004): for the
|
||||||
|
/// lifetime of a long-lived SignalR circuit <c>HttpContext</c> is <c>null</c>
|
||||||
|
/// (or, worse, a stale/foreign context), so a later re-evaluation —
|
||||||
|
/// e.g. <c><AuthorizeView></c> re-rendering — would otherwise see an
|
||||||
|
/// unauthenticated principal and render the wrong UI.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvider
|
public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvider
|
||||||
{
|
{
|
||||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
private readonly Task<AuthenticationState> _circuitAuthState;
|
||||||
|
|
||||||
public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor)
|
public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor)
|
||||||
{
|
{
|
||||||
_httpContextAccessor = httpContextAccessor;
|
// Snapshot the principal at circuit-construction time. HttpContext is
|
||||||
|
// valid here (initial HTTP request) and will not be afterwards.
|
||||||
|
var user = httpContextAccessor.HttpContext?.User
|
||||||
|
?? new ClaimsPrincipal(new ClaimsIdentity());
|
||||||
|
|
||||||
|
_circuitAuthState = Task.FromResult(new AuthenticationState(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task<AuthenticationState> GetAuthenticationStateAsync()
|
public override Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||||
{
|
=> _circuitAuthState;
|
||||||
var user = _httpContextAccessor.HttpContext?.User
|
|
||||||
?? new ClaimsPrincipal(new ClaimsIdentity());
|
|
||||||
|
|
||||||
return Task.FromResult(new AuthenticationState(user));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using ScadaLink.Commons.Entities.Sites;
|
||||||
|
using ScadaLink.Security;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the set of sites the current user is permitted to operate on, from
|
||||||
|
/// the <c>SiteId</c> claims attached at login (CentralUI-002).
|
||||||
|
/// <para>
|
||||||
|
/// The design (Component-CentralUI, CLAUDE.md "Security & Auth") makes the
|
||||||
|
/// Deployment role site-scoped: a Deployment user mapped through an LDAP group
|
||||||
|
/// with site-scope rules carries one <see cref="JwtTokenService.SiteIdClaimType"/>
|
||||||
|
/// claim per permitted site (the claim value is the integer <c>Site.Id</c>).
|
||||||
|
/// A Deployment user with no <c>SiteId</c> claim — and any Admin/Design user — is
|
||||||
|
/// system-wide.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Deployment and Monitoring pages must filter every site/instance list through
|
||||||
|
/// <see cref="FilterSitesAsync"/> and re-check <see cref="IsSiteAllowedAsync"/>
|
||||||
|
/// before any cross-site command, so a scoped user cannot view or act on sites
|
||||||
|
/// outside their grant.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SiteScopeService
|
||||||
|
{
|
||||||
|
private readonly AuthenticationStateProvider _authStateProvider;
|
||||||
|
private (bool IsSystemWide, IReadOnlySet<int> Sites)? _cached;
|
||||||
|
|
||||||
|
public SiteScopeService(AuthenticationStateProvider authStateProvider)
|
||||||
|
{
|
||||||
|
_authStateProvider = authStateProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the user is not restricted to a site subset (no <c>SiteId</c>
|
||||||
|
/// claims). System-wide users see and act on every site.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> IsSystemWideAsync()
|
||||||
|
=> (await ResolveAsync()).IsSystemWide;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The set of <c>Site.Id</c> values the user may operate on. Empty for a
|
||||||
|
/// system-wide user (callers should consult <see cref="IsSystemWideAsync"/>
|
||||||
|
/// or use the filter/allowed helpers, which already account for that).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IReadOnlySet<int>> PermittedSiteIdsAsync()
|
||||||
|
=> (await ResolveAsync()).Sites;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the subset of <paramref name="sites"/> the user is permitted to
|
||||||
|
/// see. A system-wide user gets the full list back unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<Site>> FilterSitesAsync(IEnumerable<Site> sites)
|
||||||
|
{
|
||||||
|
var (isSystemWide, allowed) = await ResolveAsync();
|
||||||
|
if (isSystemWide)
|
||||||
|
return sites.ToList();
|
||||||
|
return sites.Where(s => allowed.Contains(s.Id)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the user may operate on the site with the given <c>Site.Id</c>.
|
||||||
|
/// Must be re-checked server-side before any mutating cross-site command.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> IsSiteAllowedAsync(int siteId)
|
||||||
|
{
|
||||||
|
var (isSystemWide, allowed) = await ResolveAsync();
|
||||||
|
return isSystemWide || allowed.Contains(siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(bool IsSystemWide, IReadOnlySet<int> Sites)> ResolveAsync()
|
||||||
|
{
|
||||||
|
if (_cached is { } cached)
|
||||||
|
return cached;
|
||||||
|
|
||||||
|
var state = await _authStateProvider.GetAuthenticationStateAsync();
|
||||||
|
var siteClaims = state.User.FindAll(JwtTokenService.SiteIdClaimType);
|
||||||
|
|
||||||
|
var ids = new HashSet<int>();
|
||||||
|
foreach (var claim in siteClaims)
|
||||||
|
{
|
||||||
|
if (int.TryParse(claim.Value, out var id))
|
||||||
|
ids.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No SiteId claims => system-wide. This mirrors SiteScopeAuthorizationHandler:
|
||||||
|
// absence of scope rules means an unrestricted deployer.
|
||||||
|
var result = (IsSystemWide: ids.Count == 0, Sites: (IReadOnlySet<int>)ids);
|
||||||
|
_cached = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||||
@inject ISiteRepository SiteRepository
|
@inject ISiteRepository SiteRepository
|
||||||
|
@inject ScadaLink.CentralUI.Auth.SiteScopeService SiteScope
|
||||||
@inject DebugStreamService DebugStreamService
|
@inject DebugStreamService DebugStreamService
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
@@ -296,7 +297,9 @@
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
// Site scoping (CentralUI-002): a scoped Deployment user may only
|
||||||
|
// debug sites they are permitted on.
|
||||||
|
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -358,6 +361,14 @@
|
|||||||
_siteInstances.Clear();
|
_siteInstances.Clear();
|
||||||
_selectedInstanceId = 0;
|
_selectedInstanceId = 0;
|
||||||
if (_selectedSiteId == 0) return;
|
if (_selectedSiteId == 0) return;
|
||||||
|
// Site scoping (CentralUI-002): re-check the claim server-side — a query
|
||||||
|
// string or stale localStorage value could name a site outside the grant.
|
||||||
|
if (!await SiteScope.IsSiteAllowedAsync(_selectedSiteId))
|
||||||
|
{
|
||||||
|
_selectedSiteId = 0;
|
||||||
|
_toast.ShowError("You are not permitted to debug instances on that site.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_siteInstances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId))
|
_siteInstances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId))
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||||
@inject IDeploymentManagerRepository DeploymentManagerRepository
|
@inject IDeploymentManagerRepository DeploymentManagerRepository
|
||||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||||
|
@inject ScadaLink.CentralUI.Auth.SiteScopeService SiteScope
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
@@ -245,13 +246,23 @@
|
|||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_records = (await DeploymentManagerRepository.GetAllDeploymentRecordsAsync())
|
// Build instance lookups first — site scoping (CentralUI-002) filters
|
||||||
.OrderByDescending(r => r.DeployedAt)
|
// deployment records by the site of their instance.
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// Build instance name lookup
|
|
||||||
var instances = await TemplateEngineRepository.GetAllInstancesAsync();
|
var instances = await TemplateEngineRepository.GetAllInstancesAsync();
|
||||||
_instanceNames = instances.ToDictionary(i => i.Id, i => i.UniqueName);
|
_instanceNames = instances.ToDictionary(i => i.Id, i => i.UniqueName);
|
||||||
|
var instanceSiteIds = instances.ToDictionary(i => i.Id, i => i.SiteId);
|
||||||
|
|
||||||
|
var systemWide = await SiteScope.IsSystemWideAsync();
|
||||||
|
var permittedSiteIds = systemWide
|
||||||
|
? null
|
||||||
|
: await SiteScope.PermittedSiteIdsAsync();
|
||||||
|
|
||||||
|
_records = (await DeploymentManagerRepository.GetAllDeploymentRecordsAsync())
|
||||||
|
.Where(r => permittedSiteIds == null
|
||||||
|
|| (instanceSiteIds.TryGetValue(r.InstanceId, out var sid)
|
||||||
|
&& permittedSiteIds.Contains(sid)))
|
||||||
|
.OrderByDescending(r => r.DeployedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
_totalPages = Math.Max(1, (int)Math.Ceiling(_records.Count / (double)PageSize));
|
_totalPages = Math.Max(1, (int)Math.Ceiling(_records.Count / (double)PageSize));
|
||||||
if (_currentPage > _totalPages) _currentPage = 1;
|
if (_currentPage > _totalPages) _currentPage = 1;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||||
@inject ISiteRepository SiteRepository
|
@inject ISiteRepository SiteRepository
|
||||||
|
@inject ScadaLink.CentralUI.Auth.SiteScopeService SiteScope
|
||||||
@inject InstanceService InstanceService
|
@inject InstanceService InstanceService
|
||||||
@inject IFlatteningPipeline FlatteningPipeline
|
@inject IFlatteningPipeline FlatteningPipeline
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@@ -377,6 +378,17 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Site scoping (CentralUI-002): a scoped Deployment user must not be
|
||||||
|
// able to configure or deploy an instance on a site outside their
|
||||||
|
// grant by navigating straight to its URL.
|
||||||
|
if (!await SiteScope.IsSiteAllowedAsync(_instance.SiteId))
|
||||||
|
{
|
||||||
|
_instance = null;
|
||||||
|
_errorMessage = "You are not permitted to manage instances on this site.";
|
||||||
|
_loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Identity
|
// Identity
|
||||||
var template = await TemplateEngineRepository.GetTemplateByIdAsync(_instance.TemplateId);
|
var template = await TemplateEngineRepository.GetTemplateByIdAsync(_instance.TemplateId);
|
||||||
_templateName = template?.Name ?? $"#{_instance.TemplateId}";
|
_templateName = template?.Name ?? $"#{_instance.TemplateId}";
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||||
@inject ISiteRepository SiteRepository
|
@inject ISiteRepository SiteRepository
|
||||||
|
@inject ScadaLink.CentralUI.Auth.SiteScopeService SiteScope
|
||||||
@inject InstanceService InstanceService
|
@inject InstanceService InstanceService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@@ -93,7 +94,9 @@
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
|
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
|
||||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
// Site scoping (CentralUI-002): a scoped Deployment user may only
|
||||||
|
// create instances on sites they are permitted on.
|
||||||
|
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
|
||||||
|
|
||||||
_allAreas.Clear();
|
_allAreas.Clear();
|
||||||
foreach (var site in _sites)
|
foreach (var site in _sites)
|
||||||
@@ -124,6 +127,13 @@
|
|||||||
if (string.IsNullOrWhiteSpace(_createName)) { _formError = "Instance name is required."; return; }
|
if (string.IsNullOrWhiteSpace(_createName)) { _formError = "Instance name is required."; return; }
|
||||||
if (_createTemplateId == 0) { _formError = "Select a template."; return; }
|
if (_createTemplateId == 0) { _formError = "Select a template."; return; }
|
||||||
if (_createSiteId == 0) { _formError = "Select a site."; return; }
|
if (_createSiteId == 0) { _formError = "Select a site."; return; }
|
||||||
|
// Site scoping (CentralUI-002): re-check server-side before the mutating
|
||||||
|
// command, independent of what the site dropdown was populated with.
|
||||||
|
if (!await SiteScope.IsSiteAllowedAsync(_createSiteId))
|
||||||
|
{
|
||||||
|
_formError = "You are not permitted to create instances on the selected site.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
@inject AreaService AreaService
|
@inject AreaService AreaService
|
||||||
@inject InstanceService InstanceService
|
@inject InstanceService InstanceService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
@inject ScadaLink.CentralUI.Auth.SiteScopeService SiteScope
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
@inject IDialogService Dialog
|
@inject IDialogService Dialog
|
||||||
@@ -225,8 +226,13 @@
|
|||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_allInstances = (await TemplateEngineRepository.GetAllInstancesAsync()).ToList();
|
// Site scoping (CentralUI-002): a scoped Deployment user only sees the
|
||||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
// sites — and therefore the areas/instances — they are permitted on.
|
||||||
|
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
|
||||||
|
var permittedSiteIds = _sites.Select(s => s.Id).ToHashSet();
|
||||||
|
_allInstances = (await TemplateEngineRepository.GetAllInstancesAsync())
|
||||||
|
.Where(i => permittedSiteIds.Contains(i.SiteId))
|
||||||
|
.ToList();
|
||||||
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
|
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
|
||||||
|
|
||||||
_allAreas.Clear();
|
_allAreas.Clear();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
@using ScadaLink.Commons.Messages.RemoteQuery
|
@using ScadaLink.Commons.Messages.RemoteQuery
|
||||||
@using ScadaLink.Communication
|
@using ScadaLink.Communication
|
||||||
@inject ISiteRepository SiteRepository
|
@inject ISiteRepository SiteRepository
|
||||||
|
@inject ScadaLink.CentralUI.Auth.SiteScopeService SiteScope
|
||||||
@inject CommunicationService CommunicationService
|
@inject CommunicationService CommunicationService
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
@@ -212,9 +213,16 @@
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
// Site scoping (CentralUI-002): a scoped Deployment user may only query
|
||||||
|
// event logs for the sites they are permitted on.
|
||||||
|
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// _sites is already filtered, so membership IS the scope check.
|
||||||
|
private bool SelectedSiteIsPermitted =>
|
||||||
|
!string.IsNullOrEmpty(_selectedSiteId)
|
||||||
|
&& _sites.Any(s => s.SiteIdentifier == _selectedSiteId);
|
||||||
|
|
||||||
private async Task Search()
|
private async Task Search()
|
||||||
{
|
{
|
||||||
_entries = new();
|
_entries = new();
|
||||||
@@ -237,6 +245,14 @@
|
|||||||
{
|
{
|
||||||
_searching = true;
|
_searching = true;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
|
// Site scoping (CentralUI-002): re-check before querying — the dropdown is
|
||||||
|
// filtered, but the selection must not be trusted on its own.
|
||||||
|
if (!SelectedSiteIsPermitted)
|
||||||
|
{
|
||||||
|
_errorMessage = "You are not permitted to view event logs for that site.";
|
||||||
|
_searching = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var request = new EventLogQueryRequest(
|
var request = new EventLogQueryRequest(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
@using ScadaLink.Commons.Types.Enums
|
@using ScadaLink.Commons.Types.Enums
|
||||||
@using ScadaLink.Communication
|
@using ScadaLink.Communication
|
||||||
@inject ISiteRepository SiteRepository
|
@inject ISiteRepository SiteRepository
|
||||||
|
@inject ScadaLink.CentralUI.Auth.SiteScopeService SiteScope
|
||||||
@inject CommunicationService CommunicationService
|
@inject CommunicationService CommunicationService
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
@inject IDialogService Dialog
|
@inject IDialogService Dialog
|
||||||
@@ -360,9 +361,17 @@
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
// Site scoping (CentralUI-002): a scoped Deployment user may only inspect
|
||||||
|
// and act on parked messages for the sites they are permitted on.
|
||||||
|
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True only when the currently selected SiteIdentifier is one this user is
|
||||||
|
// permitted on. _sites is already filtered, so membership IS the scope check.
|
||||||
|
private bool SelectedSiteIsPermitted =>
|
||||||
|
!string.IsNullOrEmpty(_selectedSiteId)
|
||||||
|
&& _sites.Any(s => s.SiteIdentifier == _selectedSiteId);
|
||||||
|
|
||||||
private async Task OnSiteChanged(ChangeEventArgs e)
|
private async Task OnSiteChanged(ChangeEventArgs e)
|
||||||
{
|
{
|
||||||
_selectedSiteId = e.Value?.ToString() ?? string.Empty;
|
_selectedSiteId = e.Value?.ToString() ?? string.Empty;
|
||||||
@@ -393,6 +402,15 @@
|
|||||||
{
|
{
|
||||||
_searching = true;
|
_searching = true;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
|
// Site scoping (CentralUI-002): re-check before querying — the dropdown is
|
||||||
|
// filtered, but the selection must not be trusted on its own.
|
||||||
|
if (!SelectedSiteIsPermitted)
|
||||||
|
{
|
||||||
|
_errorMessage = "You are not permitted to view parked messages for that site.";
|
||||||
|
_messages = null;
|
||||||
|
_searching = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var request = new ParkedMessageQueryRequest(
|
var request = new ParkedMessageQueryRequest(
|
||||||
@@ -557,6 +575,7 @@
|
|||||||
{
|
{
|
||||||
var ids = _selectedIds.ToList();
|
var ids = _selectedIds.ToList();
|
||||||
if (ids.Count == 0) return;
|
if (ids.Count == 0) return;
|
||||||
|
if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return; }
|
||||||
|
|
||||||
var confirmed = await Dialog.ConfirmAsync(
|
var confirmed = await Dialog.ConfirmAsync(
|
||||||
"Retry parked messages",
|
"Retry parked messages",
|
||||||
@@ -587,6 +606,7 @@
|
|||||||
{
|
{
|
||||||
var ids = _selectedIds.ToList();
|
var ids = _selectedIds.ToList();
|
||||||
if (ids.Count == 0) return;
|
if (ids.Count == 0) return;
|
||||||
|
if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return; }
|
||||||
|
|
||||||
var confirmed = await Dialog.ConfirmAsync(
|
var confirmed = await Dialog.ConfirmAsync(
|
||||||
"Discard parked messages",
|
"Discard parked messages",
|
||||||
@@ -618,6 +638,7 @@
|
|||||||
|
|
||||||
private async Task RetrySingle(ParkedMessageEntry msg)
|
private async Task RetrySingle(ParkedMessageEntry msg)
|
||||||
{
|
{
|
||||||
|
if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return; }
|
||||||
_actionInProgress = true;
|
_actionInProgress = true;
|
||||||
_activeAction = "Retry";
|
_activeAction = "Retry";
|
||||||
try
|
try
|
||||||
@@ -638,6 +659,7 @@
|
|||||||
|
|
||||||
private async Task<bool> DiscardSingle(ParkedMessageEntry msg)
|
private async Task<bool> DiscardSingle(ParkedMessageEntry msg)
|
||||||
{
|
{
|
||||||
|
if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return false; }
|
||||||
var confirmed = await Dialog.ConfirmAsync(
|
var confirmed = await Dialog.ConfirmAsync(
|
||||||
"Discard parked message",
|
"Discard parked message",
|
||||||
$"Permanently discard message {ShortId(msg.MessageId)}? This cannot be undone.",
|
$"Permanently discard message {ShortId(msg.MessageId)}? This cannot be undone.",
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-call console capture for the Test Run sandbox.
|
||||||
|
/// <para>
|
||||||
|
/// Sandbox scripts use <c>System.Console.WriteLine</c> for ad-hoc output. The
|
||||||
|
/// sandbox needs to capture that output per execution. <c>Console.Out</c> is,
|
||||||
|
/// however, <b>process-global</b>: redirecting it with <c>Console.SetOut</c> for
|
||||||
|
/// the duration of one run corrupts any other run executing concurrently —
|
||||||
|
/// outputs interleave, and whichever run finishes first restores
|
||||||
|
/// <c>Console.Out</c> while the others are still writing (CentralUI-003).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// This writer is installed into <c>Console.Out</c>/<c>Console.Error</c>
|
||||||
|
/// <b>exactly once</b> (see <see cref="Install"/>) and never removed. Each
|
||||||
|
/// concurrent run pushes its own buffer onto an <see cref="AsyncLocal{T}"/>
|
||||||
|
/// scope via <see cref="BeginCapture"/>; writes on that run's logical call-tree
|
||||||
|
/// land in that run's buffer only. Writes made on threads with no active
|
||||||
|
/// capture scope (i.e. genuine host-process console output) fall through to the
|
||||||
|
/// original writer. No process-global mutation happens per run.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SandboxConsoleCapture : TextWriter
|
||||||
|
{
|
||||||
|
private static readonly object InstallLock = new();
|
||||||
|
private static SandboxConsoleCapture? _outInstance;
|
||||||
|
private static SandboxConsoleCapture? _errorInstance;
|
||||||
|
|
||||||
|
private readonly TextWriter _fallback;
|
||||||
|
private readonly AsyncLocal<StringWriter?> _current = new();
|
||||||
|
|
||||||
|
private SandboxConsoleCapture(TextWriter fallback) => _fallback = fallback;
|
||||||
|
|
||||||
|
public override Encoding Encoding => _fallback.Encoding;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Installs the routing writers into <see cref="Console.Out"/> and
|
||||||
|
/// <see cref="Console.Error"/> once for the process. Idempotent and
|
||||||
|
/// thread-safe. Subsequent calls return the already-installed instances.
|
||||||
|
/// </summary>
|
||||||
|
public static (SandboxConsoleCapture Out, SandboxConsoleCapture Error) Install()
|
||||||
|
{
|
||||||
|
if (_outInstance != null && _errorInstance != null)
|
||||||
|
return (_outInstance, _errorInstance);
|
||||||
|
|
||||||
|
lock (InstallLock)
|
||||||
|
{
|
||||||
|
if (_outInstance == null)
|
||||||
|
{
|
||||||
|
_outInstance = new SandboxConsoleCapture(Console.Out);
|
||||||
|
Console.SetOut(_outInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_errorInstance == null)
|
||||||
|
{
|
||||||
|
_errorInstance = new SandboxConsoleCapture(Console.Error);
|
||||||
|
Console.SetError(_errorInstance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (_outInstance, _errorInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Begins a capture scope on the current logical (async) call-tree. All
|
||||||
|
/// console writes from this point until the returned scope is disposed are
|
||||||
|
/// routed into <paramref name="buffer"/> instead of the original writer.
|
||||||
|
/// The scope is restored on dispose, so nesting and concurrent scopes on
|
||||||
|
/// other call-trees are unaffected.
|
||||||
|
/// </summary>
|
||||||
|
public CaptureScope BeginCapture(StringWriter buffer)
|
||||||
|
{
|
||||||
|
var previous = _current.Value;
|
||||||
|
_current.Value = buffer;
|
||||||
|
return new CaptureScope(this, previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(char value) => Target.Write(value);
|
||||||
|
|
||||||
|
public override void Write(string? value) => Target.Write(value);
|
||||||
|
|
||||||
|
public override void Write(char[] buffer, int index, int count) =>
|
||||||
|
Target.Write(buffer, index, count);
|
||||||
|
|
||||||
|
public override void WriteLine() => Target.WriteLine();
|
||||||
|
|
||||||
|
public override void WriteLine(string? value) => Target.WriteLine(value);
|
||||||
|
|
||||||
|
private TextWriter Target => _current.Value ?? _fallback;
|
||||||
|
|
||||||
|
internal readonly struct CaptureScope : IDisposable
|
||||||
|
{
|
||||||
|
private readonly SandboxConsoleCapture _owner;
|
||||||
|
private readonly StringWriter? _previous;
|
||||||
|
|
||||||
|
internal CaptureScope(SandboxConsoleCapture owner, StringWriter? previous)
|
||||||
|
{
|
||||||
|
_owner = owner;
|
||||||
|
_previous = previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _owner._current.Value = _previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -165,8 +165,10 @@ public class ScriptAnalysisService
|
|||||||
/// because a shared script has no template siblings in this context.
|
/// because a shared script has no template siblings in this context.
|
||||||
/// For the SandboxInboundScriptHost surface, every <c>Route</c> call throws
|
/// For the SandboxInboundScriptHost surface, every <c>Route</c> call throws
|
||||||
/// because cross-site routing needs a deployed site.
|
/// because cross-site routing needs a deployed site.
|
||||||
/// Console.Out / Console.Error are redirected per-call so writes from
|
/// Console.Out / Console.Error are captured per-call via an AsyncLocal
|
||||||
/// the script land in the result.
|
/// scope (see <see cref="SandboxConsoleCapture"/>) so writes from the script
|
||||||
|
/// land in the result without mutating process-global Console state — two
|
||||||
|
/// concurrent Test Runs do not interfere with each other.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<SandboxRunResult> RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct)
|
public async Task<SandboxRunResult> RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -377,16 +379,19 @@ public class ScriptAnalysisService
|
|||||||
Instance = instanceContext,
|
Instance = instanceContext,
|
||||||
};
|
};
|
||||||
|
|
||||||
var originalOut = Console.Out;
|
// Console capture is routed per-call via an AsyncLocal scope (CentralUI-003).
|
||||||
var originalError = Console.Error;
|
// Console.Out is process-global, so it must NOT be redirected per run — two
|
||||||
|
// concurrent Test Runs would interleave output and the first to finish would
|
||||||
|
// restore Console.Out while the other is still writing. SandboxConsoleCapture
|
||||||
|
// installs routing writers once and scopes capture to this call-tree only.
|
||||||
|
var (captureOut, captureError) = SandboxConsoleCapture.Install();
|
||||||
var captured = new StringWriter();
|
var captured = new StringWriter();
|
||||||
|
using var outScope = captureOut.BeginCapture(captured);
|
||||||
|
using var errorScope = captureError.BeginCapture(captured);
|
||||||
|
|
||||||
var stopwatch = Stopwatch.StartNew();
|
var stopwatch = Stopwatch.StartNew();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Console.SetOut(captured);
|
|
||||||
Console.SetError(captured);
|
|
||||||
|
|
||||||
// Run on a thread-pool thread with no SynchronizationContext: a
|
// Run on a thread-pool thread with no SynchronizationContext: a
|
||||||
// bound script's Instance.SetAttribute / Attributes[...] block
|
// bound script's Instance.SetAttribute / Attributes[...] block
|
||||||
// synchronously on cross-site I/O (the API surface is sync by
|
// synchronously on cross-site I/O (the API surface is sync by
|
||||||
@@ -437,11 +442,9 @@ public class ScriptAnalysisService
|
|||||||
$"{inner.GetType().Name}: {inner.Message}",
|
$"{inner.GetType().Name}: {inner.Message}",
|
||||||
SandboxErrorKind.RuntimeError, stopwatch.ElapsedMilliseconds, null);
|
SandboxErrorKind.RuntimeError, stopwatch.ElapsedMilliseconds, null);
|
||||||
}
|
}
|
||||||
finally
|
// outScope / errorScope are disposed by their `using` declarations when the
|
||||||
{
|
// method returns, restoring the previous capture scope on this call-tree
|
||||||
Console.SetOut(originalOut);
|
// without touching process-global Console state.
|
||||||
Console.SetError(originalError);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Dictionary<string, object?> ConvertJsonParameters(
|
private static Dictionary<string, object?> ConvertJsonParameters(
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<AuthenticationStateProvider, CookieAuthenticationStateProvider>();
|
services.AddScoped<AuthenticationStateProvider, CookieAuthenticationStateProvider>();
|
||||||
services.AddCascadingAuthenticationState();
|
services.AddCascadingAuthenticationState();
|
||||||
|
|
||||||
|
// Resolves the current user's permitted site set from their SiteId claims
|
||||||
|
// so Deployment/Monitoring pages can enforce site scoping (CentralUI-002).
|
||||||
|
services.AddScoped<SiteScopeService>();
|
||||||
|
|
||||||
// Centralised dialog service: pages inject IDialogService and a single
|
// Centralised dialog service: pages inject IDialogService and a single
|
||||||
// <DialogHost /> in MainLayout renders the active dialog. See
|
// <DialogHost /> in MainLayout renders the active dialog. See
|
||||||
// Components/Shared/IDialogService.cs.
|
// Components/Shared/IDialogService.cs.
|
||||||
|
|||||||
@@ -183,6 +183,15 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unsubscribe the failed stream on the *previous* endpoint before reconnecting.
|
||||||
|
// This cancels the local subscription CTS and -- where the channel is still
|
||||||
|
// alive -- propagates gRPC cancellation to the site so its SiteStreamGrpcServer
|
||||||
|
// stops the StreamRelayActor for this correlation ID, rather than leaving a
|
||||||
|
// zombie relay actor until TCP RST / keepalive eventually detects the loss.
|
||||||
|
var previousEndpoint = _useNodeA ? _grpcNodeAAddress : _grpcNodeBAddress;
|
||||||
|
var previousClient = _grpcFactory.GetOrCreate(_siteIdentifier, previousEndpoint);
|
||||||
|
previousClient.Unsubscribe(_correlationId);
|
||||||
|
|
||||||
// Flip to the other node
|
// Flip to the other node
|
||||||
_useNodeA = !_useNodeA;
|
_useNodeA = !_useNodeA;
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,32 @@ public class SiteStreamGrpcClient : IAsyncDisposable
|
|||||||
_subscriptions[correlationId] = cts;
|
_subscriptions[correlationId] = cts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a subscription's CancellationTokenSource for a correlation ID.
|
||||||
|
/// If an entry already exists for that correlation ID (a reconnect race where two
|
||||||
|
/// <see cref="SubscribeAsync"/> calls briefly share an ID), the prior CTS is
|
||||||
|
/// cancelled and disposed so it cannot leak. Internal for testability.
|
||||||
|
/// </summary>
|
||||||
|
internal void RegisterSubscription(string correlationId, CancellationTokenSource cts)
|
||||||
|
{
|
||||||
|
if (_subscriptions.TryGetValue(correlationId, out var prior) && !ReferenceEquals(prior, cts))
|
||||||
|
{
|
||||||
|
prior.Cancel();
|
||||||
|
prior.Dispose();
|
||||||
|
}
|
||||||
|
_subscriptions[correlationId] = cts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the subscription entry for a correlation ID only if the stored CTS is
|
||||||
|
/// exactly the one supplied. A racing replacement stream may already own the slot,
|
||||||
|
/// in which case this is a no-op. Internal for testability.
|
||||||
|
/// </summary>
|
||||||
|
internal void RemoveSubscription(string correlationId, CancellationTokenSource cts)
|
||||||
|
{
|
||||||
|
_subscriptions.TryRemove(new KeyValuePair<string, CancellationTokenSource>(correlationId, cts));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens a server-streaming subscription for a specific instance.
|
/// Opens a server-streaming subscription for a specific instance.
|
||||||
/// This is a long-running async method; the caller launches it as a background task.
|
/// This is a long-running async method; the caller launches it as a background task.
|
||||||
@@ -74,7 +100,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable
|
|||||||
throw new InvalidOperationException("Cannot subscribe on a test-only client.");
|
throw new InvalidOperationException("Cannot subscribe on a test-only client.");
|
||||||
|
|
||||||
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
_subscriptions[correlationId] = cts;
|
RegisterSubscription(correlationId, cts);
|
||||||
|
|
||||||
var request = new InstanceStreamRequest
|
var request = new InstanceStreamRequest
|
||||||
{
|
{
|
||||||
@@ -103,7 +129,8 @@ public class SiteStreamGrpcClient : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_subscriptions.TryRemove(correlationId, out _);
|
// Remove only our own entry -- a racing reconnect may already own the slot.
|
||||||
|
RemoveSubscription(correlationId, cts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,17 +27,15 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
|||||||
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads a template together with its child members — Attributes, Alarms,
|
||||||
|
/// Scripts and Compositions — eager-loaded so callers get the full template
|
||||||
|
/// aggregate in a single round-trip. "Children" here refers to the template's
|
||||||
|
/// member collections, not derived/sub templates.
|
||||||
|
/// </summary>
|
||||||
public async Task<Template?> GetTemplateWithChildrenAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<Template?> GetTemplateWithChildrenAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var template = await GetTemplateByIdAsync(id, cancellationToken);
|
return await GetTemplateByIdAsync(id, cancellationToken);
|
||||||
if (template == null) return null;
|
|
||||||
|
|
||||||
// Load all templates that have this template as parent
|
|
||||||
var children = await _context.Templates
|
|
||||||
.Where(t => t.ParentTemplateId == id)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
return template;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Template>> GetAllTemplatesAsync(CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<Template>> GetAllTemplatesAsync(CancellationToken cancellationToken = default)
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.CommandLine;
|
||||||
|
using ScadaLink.CLI;
|
||||||
|
using ScadaLink.CLI.Commands;
|
||||||
|
|
||||||
|
namespace ScadaLink.CLI.Tests;
|
||||||
|
|
||||||
|
public class FormatResolutionTests
|
||||||
|
{
|
||||||
|
private static (Option<string> formatOption, RootCommand root) BuildHarness()
|
||||||
|
{
|
||||||
|
var formatOption = new Option<string>("--format") { Recursive = true };
|
||||||
|
var root = new RootCommand();
|
||||||
|
root.Add(formatOption);
|
||||||
|
return (formatOption, root);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveFormat_ExplicitFlag_OverridesConfig()
|
||||||
|
{
|
||||||
|
var (formatOption, root) = BuildHarness();
|
||||||
|
var result = root.Parse(new[] { "--format", "table" });
|
||||||
|
var config = new CliConfig { DefaultFormat = "json" };
|
||||||
|
|
||||||
|
var format = CommandHelpers.ResolveFormat(result, formatOption, config);
|
||||||
|
|
||||||
|
Assert.Equal("table", format);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveFormat_FlagAbsent_UsesConfigDefaultFormat()
|
||||||
|
{
|
||||||
|
// Regression for CLI-001: when --format is not supplied, the config-file /
|
||||||
|
// env-var DefaultFormat must be honoured instead of always falling back to "json".
|
||||||
|
var (formatOption, root) = BuildHarness();
|
||||||
|
var result = root.Parse(Array.Empty<string>());
|
||||||
|
var config = new CliConfig { DefaultFormat = "table" };
|
||||||
|
|
||||||
|
var format = CommandHelpers.ResolveFormat(result, formatOption, config);
|
||||||
|
|
||||||
|
Assert.Equal("table", format);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveFormat_FlagAbsent_AndNoConfig_DefaultsToJson()
|
||||||
|
{
|
||||||
|
var (formatOption, root) = BuildHarness();
|
||||||
|
var result = root.Parse(Array.Empty<string>());
|
||||||
|
var config = new CliConfig { DefaultFormat = "json" };
|
||||||
|
|
||||||
|
var format = CommandHelpers.ResolveFormat(result, formatOption, config);
|
||||||
|
|
||||||
|
Assert.Equal("json", format);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using ScadaLink.CentralUI.Auth;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Tests.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for CentralUI-004. The provider used to read
|
||||||
|
/// <see cref="IHttpContextAccessor.HttpContext"/> on every call; once the Blazor
|
||||||
|
/// circuit is established that context is gone, so later re-evaluations saw an
|
||||||
|
/// unauthenticated principal. The provider must snapshot the principal once at
|
||||||
|
/// construction (during the initial HTTP request) and serve it for the circuit.
|
||||||
|
/// </summary>
|
||||||
|
public class CookieAuthenticationStateProviderTests
|
||||||
|
{
|
||||||
|
private static ClaimsPrincipal AuthenticatedUser(string name)
|
||||||
|
{
|
||||||
|
var identity = new ClaimsIdentity(
|
||||||
|
new[] { new Claim(ClaimTypes.Name, name) },
|
||||||
|
authenticationType: "TestCookie");
|
||||||
|
return new ClaimsPrincipal(identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAuthenticationStateAsync_ReturnsAuthenticatedUser_WhenHttpContextPresent()
|
||||||
|
{
|
||||||
|
var accessor = new HttpContextAccessor
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext { User = AuthenticatedUser("alice") }
|
||||||
|
};
|
||||||
|
|
||||||
|
var provider = new CookieAuthenticationStateProvider(accessor);
|
||||||
|
var state = await provider.GetAuthenticationStateAsync();
|
||||||
|
|
||||||
|
Assert.True(state.User.Identity?.IsAuthenticated);
|
||||||
|
Assert.Equal("alice", state.User.Identity?.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAuthenticationStateAsync_StillReturnsUser_AfterHttpContextIsGone()
|
||||||
|
{
|
||||||
|
// The circuit is built during the HTTP request: HttpContext is valid then.
|
||||||
|
var accessor = new HttpContextAccessor
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext { User = AuthenticatedUser("bob") }
|
||||||
|
};
|
||||||
|
var provider = new CookieAuthenticationStateProvider(accessor);
|
||||||
|
|
||||||
|
// After the request completes, IHttpContextAccessor.HttpContext is null for
|
||||||
|
// the life of the long-lived SignalR circuit.
|
||||||
|
accessor.HttpContext = null;
|
||||||
|
|
||||||
|
var state = await provider.GetAuthenticationStateAsync();
|
||||||
|
|
||||||
|
// The pre-fix implementation returned an anonymous principal here.
|
||||||
|
Assert.True(state.User.Identity?.IsAuthenticated);
|
||||||
|
Assert.Equal("bob", state.User.Identity?.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAuthenticationStateAsync_IsStableAcrossCalls_IgnoringStaleForeignContext()
|
||||||
|
{
|
||||||
|
var accessor = new HttpContextAccessor
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext { User = AuthenticatedUser("carol") }
|
||||||
|
};
|
||||||
|
var provider = new CookieAuthenticationStateProvider(accessor);
|
||||||
|
|
||||||
|
// A stale/foreign context leaking through the AsyncLocal accessor must NOT
|
||||||
|
// change what this circuit's provider reports.
|
||||||
|
accessor.HttpContext = new DefaultHttpContext { User = AuthenticatedUser("intruder") };
|
||||||
|
|
||||||
|
var first = await provider.GetAuthenticationStateAsync();
|
||||||
|
var second = await provider.GetAuthenticationStateAsync();
|
||||||
|
|
||||||
|
Assert.Equal("carol", first.User.Identity?.Name);
|
||||||
|
Assert.Equal("carol", second.User.Identity?.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using ScadaLink.CentralUI.Auth;
|
||||||
|
using ScadaLink.Commons.Entities.Sites;
|
||||||
|
using ScadaLink.Security;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Tests.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for CentralUI-002. Site-scoped Deployment permissions are
|
||||||
|
/// written as <c>SiteId</c> claims at login but were never read — Deployment
|
||||||
|
/// pages listed and acted on every site. <see cref="SiteScopeService"/> is the
|
||||||
|
/// shared helper that reads those claims; these tests pin its behaviour.
|
||||||
|
/// </summary>
|
||||||
|
public class SiteScopeServiceTests
|
||||||
|
{
|
||||||
|
private sealed class StubAuthStateProvider : AuthenticationStateProvider
|
||||||
|
{
|
||||||
|
private readonly ClaimsPrincipal _user;
|
||||||
|
public StubAuthStateProvider(ClaimsPrincipal user) => _user = user;
|
||||||
|
public override Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||||
|
=> Task.FromResult(new AuthenticationState(_user));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SiteScopeService ForUser(params Claim[] claims)
|
||||||
|
{
|
||||||
|
var identity = new ClaimsIdentity(claims, authenticationType: "TestCookie");
|
||||||
|
return new SiteScopeService(new StubAuthStateProvider(new ClaimsPrincipal(identity)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Claim Role(string role) => new(JwtTokenService.RoleClaimType, role);
|
||||||
|
private static Claim SiteClaim(int id) => new(JwtTokenService.SiteIdClaimType, id.ToString());
|
||||||
|
|
||||||
|
private static List<Site> Sites(params int[] ids)
|
||||||
|
=> ids.Select(id => new Site($"Site{id}", $"SITE-{id}") { Id = id }).ToList();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeploymentUserWithNoSiteClaims_IsSystemWide()
|
||||||
|
{
|
||||||
|
var svc = ForUser(Role("Deployment"));
|
||||||
|
|
||||||
|
Assert.True(await svc.IsSystemWideAsync());
|
||||||
|
Assert.Empty(await svc.PermittedSiteIdsAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SystemWideUser_FilterSites_ReturnsAllSites()
|
||||||
|
{
|
||||||
|
var svc = ForUser(Role("Deployment"));
|
||||||
|
|
||||||
|
var filtered = await svc.FilterSitesAsync(Sites(1, 2, 3));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { 1, 2, 3 }, filtered.Select(s => s.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ScopedUser_FilterSites_ReturnsOnlyPermittedSites()
|
||||||
|
{
|
||||||
|
// Regression: a Deployment user scoped to sites 1 and 3 must NOT see site 2.
|
||||||
|
var svc = ForUser(Role("Deployment"), SiteClaim(1), SiteClaim(3));
|
||||||
|
|
||||||
|
var filtered = await svc.FilterSitesAsync(Sites(1, 2, 3, 4));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { 1, 3 }, filtered.Select(s => s.Id).OrderBy(x => x));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ScopedUser_IsSiteAllowed_OnlyForGrantedSites()
|
||||||
|
{
|
||||||
|
var svc = ForUser(Role("Deployment"), SiteClaim(5));
|
||||||
|
|
||||||
|
Assert.True(await svc.IsSiteAllowedAsync(5));
|
||||||
|
Assert.False(await svc.IsSiteAllowedAsync(6));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ScopedUser_IsNotSystemWide_AndReportsItsPermittedIds()
|
||||||
|
{
|
||||||
|
var svc = ForUser(Role("Deployment"), SiteClaim(7), SiteClaim(9));
|
||||||
|
|
||||||
|
Assert.False(await svc.IsSystemWideAsync());
|
||||||
|
Assert.Equal(new[] { 7, 9 }, (await svc.PermittedSiteIdsAsync()).OrderBy(x => x));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SystemWideUser_IsSiteAllowed_ForAnySite()
|
||||||
|
{
|
||||||
|
var svc = ForUser(Role("Deployment"));
|
||||||
|
|
||||||
|
Assert.True(await svc.IsSiteAllowedAsync(1));
|
||||||
|
Assert.True(await svc.IsSiteAllowedAsync(999));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -465,4 +465,89 @@ public class ScriptAnalysisServiceTests
|
|||||||
Assert.True(result.Success);
|
Assert.True(result.Success);
|
||||||
Assert.Equal("42", result.ReturnValueJson);
|
Assert.Equal("42", result.ReturnValueJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunInSandbox_CapturesConsoleOutput()
|
||||||
|
{
|
||||||
|
var result = await _svc.RunInSandboxAsync(
|
||||||
|
new SandboxRunRequest(
|
||||||
|
"System.Console.WriteLine(\"hello-sandbox\"); return 1;",
|
||||||
|
Parameters: null,
|
||||||
|
TimeoutSeconds: null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Contains("hello-sandbox", result.ConsoleOutput);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunInSandbox_ConcurrentRuns_DoNotCrossContaminateConsoleOutput()
|
||||||
|
{
|
||||||
|
// Regression test for CentralUI-003. RunInSandboxAsync used to redirect the
|
||||||
|
// process-global Console.Out/Error to a per-call StringWriter. While one run
|
||||||
|
// is mid-flight, any concurrent run's `finally` restores Console.Out to the
|
||||||
|
// ORIGINAL writer — so the long run loses every Console.WriteLine it makes
|
||||||
|
// after that point, and short runs cross-contaminate each other. The fix
|
||||||
|
// routes capture per-call via an AsyncLocal writer without mutating
|
||||||
|
// process-global Console state.
|
||||||
|
|
||||||
|
// A long-running script: writes its tag, then burns CPU, then writes again,
|
||||||
|
// repeatedly. While it spins, many short runs start and finish around it.
|
||||||
|
async Task<string> RunLong()
|
||||||
|
{
|
||||||
|
var code = @"
|
||||||
|
for (int i = 0; i < 40; i++)
|
||||||
|
{
|
||||||
|
System.Console.WriteLine(""LONG"");
|
||||||
|
long acc = 0;
|
||||||
|
for (long j = 0; j < 2_000_000; j++) acc += j;
|
||||||
|
System.Console.WriteLine(""LONG"" + acc);
|
||||||
|
}
|
||||||
|
return 0;";
|
||||||
|
var r = await _svc.RunInSandboxAsync(
|
||||||
|
new SandboxRunRequest(code, Parameters: null, TimeoutSeconds: 30),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.True(r.Success, r.Error);
|
||||||
|
return r.ConsoleOutput;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<string> RunShort(int id)
|
||||||
|
{
|
||||||
|
var code = $"for (int i = 0; i < 30; i++) System.Console.WriteLine(\"S{id}\"); return 0;";
|
||||||
|
var r = await _svc.RunInSandboxAsync(
|
||||||
|
new SandboxRunRequest(code, Parameters: null, TimeoutSeconds: 30),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.True(r.Success, r.Error);
|
||||||
|
return r.ConsoleOutput;
|
||||||
|
}
|
||||||
|
|
||||||
|
var longTask = RunLong();
|
||||||
|
var shortTasks = new List<Task<string>>();
|
||||||
|
for (var round = 0; round < 12; round++)
|
||||||
|
{
|
||||||
|
for (var k = 0; k < 4; k++)
|
||||||
|
shortTasks.Add(RunShort(round * 4 + k));
|
||||||
|
await Task.Yield();
|
||||||
|
}
|
||||||
|
|
||||||
|
var longOut = await longTask;
|
||||||
|
var shortOuts = await Task.WhenAll(shortTasks);
|
||||||
|
|
||||||
|
// The long run must have captured ALL 80 of its own writes (40 plain + 40 acc).
|
||||||
|
var longLines = longOut.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Count(l => l.StartsWith("LONG"));
|
||||||
|
Assert.Equal(80, longLines);
|
||||||
|
|
||||||
|
// No short run's output must have leaked into the long run's capture.
|
||||||
|
for (var i = 0; i < shortOuts.Length; i++)
|
||||||
|
Assert.DoesNotContain($"S{i}", longOut);
|
||||||
|
|
||||||
|
// Each short run captured exactly its own 30 lines and nothing else.
|
||||||
|
for (var i = 0; i < shortOuts.Length; i++)
|
||||||
|
{
|
||||||
|
var lines = shortOuts[i].Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
Assert.Equal(30, lines.Length);
|
||||||
|
Assert.All(lines, l => Assert.Equal($"S{i}", l.Trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ public class TopologyPageTests : BunitContext
|
|||||||
// DI registration still has to satisfy the [Inject].
|
// DI registration still has to satisfy the [Inject].
|
||||||
Services.AddScoped<IDialogService, DialogService>();
|
Services.AddScoped<IDialogService, DialogService>();
|
||||||
|
|
||||||
|
// Site scoping (CentralUI-002): Topology injects SiteScopeService to
|
||||||
|
// filter the tree by the user's permitted sites.
|
||||||
|
Services.AddScoped<ScadaLink.CentralUI.Auth.SiteScopeService>();
|
||||||
|
|
||||||
// TreeView persists expansion state via JS interop. Stub the calls so render doesn't throw.
|
// TreeView persists expansion state via JS interop. Stub the calls so render doesn't throw.
|
||||||
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
|
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
|
||||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
|
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
|
||||||
@@ -194,6 +198,52 @@ public class TopologyPageTests : BunitContext
|
|||||||
Assert.Contains(dimmedNodes, n => n.TextContent.Contains("Boilers"));
|
Assert.Contains(dimmedNodes, n => n.TextContent.Contains("Boilers"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SiteScoping_ScopedDeploymentUser_OnlySeesPermittedSites()
|
||||||
|
{
|
||||||
|
// Regression test for CentralUI-002. The SiteId claims issued at login were
|
||||||
|
// never read, so a Deployment user scoped to one site could view (and act
|
||||||
|
// on) every site's topology. Topology now filters the tree by the user's
|
||||||
|
// permitted sites via SiteScopeService.
|
||||||
|
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim("Username", "scoped-tester"),
|
||||||
|
new Claim(ScadaLink.Security.JwtTokenService.RoleClaimType, "Deployment"),
|
||||||
|
// Permitted on site 1 only.
|
||||||
|
new Claim(ScadaLink.Security.JwtTokenService.SiteIdClaimType, "1"),
|
||||||
|
}, "TestAuth"));
|
||||||
|
// Last AuthenticationStateProvider registration wins on resolution.
|
||||||
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(scopedUser));
|
||||||
|
|
||||||
|
SeedRepos(sites: new[]
|
||||||
|
{
|
||||||
|
new Site("Plant-A", "plant-a") { Id = 1 },
|
||||||
|
new Site("Plant-B", "plant-b") { Id = 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
var cut = Render<TopologyPage>();
|
||||||
|
|
||||||
|
// The permitted site is rendered; the non-permitted site is not.
|
||||||
|
Assert.Contains("Plant-A", cut.Markup);
|
||||||
|
Assert.DoesNotContain("Plant-B", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SiteScoping_SystemWideDeploymentUser_SeesAllSites()
|
||||||
|
{
|
||||||
|
// A Deployment user with no SiteId claims is system-wide and sees every site.
|
||||||
|
SeedRepos(sites: new[]
|
||||||
|
{
|
||||||
|
new Site("Plant-A", "plant-a") { Id = 1 },
|
||||||
|
new Site("Plant-B", "plant-b") { Id = 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
var cut = Render<TopologyPage>();
|
||||||
|
|
||||||
|
Assert.Contains("Plant-A", cut.Markup);
|
||||||
|
Assert.Contains("Plant-B", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DoubleClick_OnAreaLabel_EntersRenameMode()
|
public void DoubleClick_OnAreaLabel_EntersRenameMode()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -159,6 +159,34 @@ public class DebugStreamBridgeActorTests : TestKit
|
|||||||
Assert.Equal("corr-1", ctx.MockGrpcClient.SubscribeCalls[1].CorrelationId);
|
Assert.Equal("corr-1", ctx.MockGrpcClient.SubscribeCalls[1].CorrelationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void On_GrpcError_Unsubscribes_Old_Stream_Before_Reconnect()
|
||||||
|
{
|
||||||
|
// Communication-002 regression: a reconnect must unsubscribe the previous
|
||||||
|
// stream so the old node does not keep a zombie relay actor / subscription.
|
||||||
|
var ctx = CreateBridgeActor();
|
||||||
|
ctx.CommProbe.ExpectMsg<SiteEnvelope>();
|
||||||
|
|
||||||
|
var snapshot = new DebugViewSnapshot(
|
||||||
|
InstanceName,
|
||||||
|
new List<AttributeValueChanged>(),
|
||||||
|
new List<AlarmStateChanged>(),
|
||||||
|
DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
ctx.BridgeActor.Tell(snapshot);
|
||||||
|
AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
|
// Simulate gRPC error → reconnect
|
||||||
|
ctx.MockGrpcClient.SubscribeCalls[0].OnError(new Exception("Stream broken"));
|
||||||
|
|
||||||
|
// Should resubscribe...
|
||||||
|
AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 2, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
// ...and must have unsubscribed the prior correlation ID so the old node's
|
||||||
|
// relay actor is released rather than left zombie.
|
||||||
|
Assert.Contains("corr-1", ctx.MockGrpcClient.UnsubscribedCorrelationIds);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void After_MaxRetries_Terminates()
|
public void After_MaxRetries_Terminates()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -176,4 +176,49 @@ public class SiteStreamGrpcClientTests
|
|||||||
Assert.True(cts1.IsCancellationRequested);
|
Assert.True(cts1.IsCancellationRequested);
|
||||||
Assert.True(cts2.IsCancellationRequested);
|
Assert.True(cts2.IsCancellationRequested);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Communication-003 regression tests ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisterSubscription_ReusedCorrelationId_CancelsAndDisposesPriorCts()
|
||||||
|
{
|
||||||
|
// Two SubscribeAsync calls briefly sharing a correlation ID (reconnect race).
|
||||||
|
// Inserting the second must cancel + dispose the first so it does not leak.
|
||||||
|
var client = SiteStreamGrpcClient.CreateForTesting();
|
||||||
|
|
||||||
|
var first = new CancellationTokenSource();
|
||||||
|
var second = new CancellationTokenSource();
|
||||||
|
|
||||||
|
client.RegisterSubscription("corr-shared", first);
|
||||||
|
client.RegisterSubscription("corr-shared", second);
|
||||||
|
|
||||||
|
Assert.True(first.IsCancellationRequested);
|
||||||
|
// Disposed CTS throws ObjectDisposedException when its token is touched.
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => _ = first.Token);
|
||||||
|
|
||||||
|
// The second (live) CTS must remain intact.
|
||||||
|
Assert.False(second.IsCancellationRequested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveSubscription_OnlyRemovesOwnCts_NotAReplacement()
|
||||||
|
{
|
||||||
|
// First call's finally must NOT remove the second call's live entry.
|
||||||
|
var client = SiteStreamGrpcClient.CreateForTesting();
|
||||||
|
|
||||||
|
var first = new CancellationTokenSource();
|
||||||
|
var second = new CancellationTokenSource();
|
||||||
|
|
||||||
|
client.RegisterSubscription("corr-shared", first);
|
||||||
|
// A racing second SubscribeAsync replaces the entry.
|
||||||
|
client.RegisterSubscription("corr-shared", second);
|
||||||
|
|
||||||
|
// The first call's finally runs and tries to remove its (already-replaced) entry.
|
||||||
|
client.RemoveSubscription("corr-shared", first);
|
||||||
|
|
||||||
|
// The live (second) subscription must still be cancellable via Unsubscribe.
|
||||||
|
Assert.False(second.IsCancellationRequested);
|
||||||
|
client.Unsubscribe("corr-shared");
|
||||||
|
Assert.True(second.IsCancellationRequested);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ScadaLink.Commons.Entities.Templates;
|
||||||
|
using ScadaLink.ConfigurationDatabase;
|
||||||
|
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Tests;
|
||||||
|
|
||||||
|
public class TemplateEngineRepositoryTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ScadaLinkDbContext _context;
|
||||||
|
private readonly TemplateEngineRepository _repository;
|
||||||
|
|
||||||
|
public TemplateEngineRepositoryTests()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||||
|
.UseSqlite("DataSource=:memory:")
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
_context = new ScadaLinkDbContext(options);
|
||||||
|
_context.Database.OpenConnection();
|
||||||
|
_context.Database.EnsureCreated();
|
||||||
|
_repository = new TemplateEngineRepository(_context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_context.Database.CloseConnection();
|
||||||
|
_context.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTemplateWithChildrenAsync_ReturnsTemplateWithAllMemberCollectionsPopulated()
|
||||||
|
{
|
||||||
|
// Arrange: a template with one attribute, one alarm, one script and one composition.
|
||||||
|
var composed = new Template("ComposedTemplate");
|
||||||
|
_context.Templates.Add(composed);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var template = new Template("ParentTemplate");
|
||||||
|
template.Attributes.Add(new TemplateAttribute("Attr1"));
|
||||||
|
template.Alarms.Add(new TemplateAlarm("Alarm1"));
|
||||||
|
template.Scripts.Add(new TemplateScript("Script1", "return 1;"));
|
||||||
|
template.Compositions.Add(new TemplateComposition("Slot1") { ComposedTemplateId = composed.Id });
|
||||||
|
_context.Templates.Add(template);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var loaded = await _repository.GetTemplateWithChildrenAsync(template.Id);
|
||||||
|
|
||||||
|
// Assert: the method must deliver the template's child members to the caller,
|
||||||
|
// not silently drop them. Regression guard for ConfigurationDatabase-001.
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
Assert.Equal(template.Id, loaded!.Id);
|
||||||
|
Assert.Single(loaded.Attributes);
|
||||||
|
Assert.Equal("Attr1", loaded.Attributes.First().Name);
|
||||||
|
Assert.Single(loaded.Alarms);
|
||||||
|
Assert.Equal("Alarm1", loaded.Alarms.First().Name);
|
||||||
|
Assert.Single(loaded.Scripts);
|
||||||
|
Assert.Equal("Script1", loaded.Scripts.First().Name);
|
||||||
|
Assert.Single(loaded.Compositions);
|
||||||
|
Assert.Equal("Slot1", loaded.Compositions.First().InstanceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTemplateWithChildrenAsync_ReturnsNull_WhenTemplateDoesNotExist()
|
||||||
|
{
|
||||||
|
var loaded = await _repository.GetTemplateWithChildrenAsync(9999);
|
||||||
|
|
||||||
|
Assert.Null(loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTemplateWithChildrenAsync_PreservesParentTemplateId_ForInheritanceChainWalk()
|
||||||
|
{
|
||||||
|
// FlatteningPipeline.BuildTemplateChainAsync walks ParentTemplateId upward.
|
||||||
|
// The result of GetTemplateWithChildrenAsync must carry that link intact.
|
||||||
|
var baseTemplate = new Template("BaseTemplate");
|
||||||
|
_context.Templates.Add(baseTemplate);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var derived = new Template("DerivedTemplate") { ParentTemplateId = baseTemplate.Id };
|
||||||
|
_context.Templates.Add(derived);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var loaded = await _repository.GetTemplateWithChildrenAsync(derived.Id);
|
||||||
|
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
Assert.Equal(baseTemplate.Id, loaded!.ParentTemplateId);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user