Resolve all 33 medium-severity architecture review findings

Second slice of the 2026-09-09 whole-system review remediation
(docs/plans/2026-09-09-arch-review.md §10), on top of the High slice 146a1323.

Runtime/devices: SR-2 alarm on-trigger integration scope; SR-3 correlated NativeAlarmActor
subscribe retry; SR-4 immutable audited DB target; SR-5 ordered InstanceActor stop/flush;
SR-12 rehydration fence; SR-13 S&F gate recheck per delivery; DC-4 no OPC UA security
downgrade; DC-5 alarm snapshot for every new subscriber; ORCH-1 sync-dispose joins the pump.

Management/UI: MG-2 site scope on audit tree + Flow Debugger Replay; MG-3 stale-probe memo
removed; MG-4 server-owned bulk-deploy deadline; UI-5 sandbox runs in a killable child
process (new CentralUI.SandboxWorker project); UI-14 bounded sandbox output/IPC.

Edge/audit: EI-1 relay audit ownership; EI-2 SkipBodyCapture suppresses error text; EI-3/EI-11
error-field redaction + caps; EI-4 single relay deadline, pre-execution-only retry; EI-5
authenticated relay provenance; EI-10 body cap before audit read.

Persistence/contracts: DL-1 global Instance.UniqueName unique index + conflict translation;
DL-2 DataConnection protocol config encrypted at rest (migration
20260909203046_EnforceGlobalInstanceNamesAndProtectProtocolConfiguration — coordinated
central upgrade, old binaries cannot read protected rows); DL-3 bounded Replay paging;
DL-6 cross-site Overwrite of a deployed instance rejected; XC-4 proto descriptor contract test.

Config/hygiene: XC-1 central listener isolation by local port; XC-2/XC-3 inert SMTP + cron
keys removed from rig settings; CM-2 env2 bootstrap guard enabled; DC-6 deploy site exit code;
DC-7 notifier retries only explicit standby rejection; XC-5 ClusterClient purged from docs.

Gates recorded in the report: build 0/0, 9 397 passed / 0 failed, rig redeployed on all
eight nodes with the migration applied. 46 Low findings remain open.
This commit is contained in:
Joseph Doherty
2026-09-09 22:05:30 -04:00
parent 146a13238f
commit 9a87d7a328
136 changed files with 5441 additions and 648 deletions
@@ -430,7 +430,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
/// </summary>
private sealed class BatchFailingAuditRepo : IAuditLogRepository
{
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default)
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
private readonly IAuditLogRepository _inner;
@@ -458,7 +458,7 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
_inner.GetKpiSnapshotAsync(window, nowUtc, ct);
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
_inner.GetExecutionTreeAsync(executionId, ct);
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
_inner.GetDistinctSourceNodesAsync(ct);
@@ -382,7 +382,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
/// </summary>
private sealed class TokenRecordingRepository : IAuditLogRepository
{
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default)
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
public CancellationToken? BatchToken { get; private set; }
@@ -427,7 +427,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
throw new NotSupportedException();
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
throw new NotSupportedException();
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
@@ -441,7 +441,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
/// </summary>
private sealed class ThrowingRepository : IAuditLogRepository
{
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default)
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
private readonly IAuditLogRepository _inner;
@@ -486,7 +486,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
_inner.GetKpiSnapshotAsync(window, nowUtc, ct);
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
_inner.GetExecutionTreeAsync(executionId, ct);
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
@@ -45,7 +45,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
/// </summary>
private sealed class RecordingRepo : IAuditLogRepository
{
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default)
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
public List<DateTime> ThresholdQueries { get; } = new();
@@ -112,7 +112,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
@@ -37,7 +37,7 @@ public class CentralAuditWriteFailuresTests : TestKit
/// </summary>
private sealed class ThrowingRepo : IAuditLogRepository
{
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default)
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
@@ -61,7 +61,7 @@ public class CentralAuditWriteFailuresTests : TestKit
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
@@ -68,7 +68,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
/// </summary>
private sealed class RecordingRepo : IAuditLogRepository
{
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default)
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
public List<AuditEvent> Inserted { get; } = new();
@@ -109,7 +109,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
@@ -124,7 +124,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
/// </summary>
private sealed class PoisonInsertRepo : IAuditLogRepository
{
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default)
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
private readonly Guid _poisonId;
@@ -175,7 +175,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
@@ -198,7 +198,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
/// </summary>
private sealed class AlwaysFailUnlessSyntheticRepo : IAuditLogRepository
{
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default)
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
public List<AuditEvent> Inserted { get; } = new();
@@ -241,7 +241,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
@@ -612,7 +612,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
/// <summary>Repository whose every insert throws, so the retry hold-back path is taken.</summary>
private sealed class AlwaysThrowingRepo : IAuditLogRepository
{
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default)
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
@@ -644,7 +644,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
=> throw new NotSupportedException();
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default)
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException();
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
@@ -20,6 +20,34 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction;
/// </summary>
public class ScadaBridgeAuditRedactorTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public void EI11_ErrorMessage_AllFieldsRedactedAndCapped(bool perTarget)
{
var opts = new AuditLogOptions { ErrorCapBytes = 128 };
if (perTarget) opts.PerTargetOverrides["target"] = new PerTargetRedactionOverride { AdditionalBodyRedactors = ["SECRET123"] };
else opts.GlobalBodyRedactors = ["SECRET123"];
var evt = NewEvent(status: AuditStatus.Failed, outcome: AuditOutcome.Failure, target: "target", errorDetail: "SECRET123", request: "SECRET123", response: "SECRET123", extra: "SECRET123");
evt = evt with { DetailsJson = AuditDetailsCodec.Serialize(Details(evt) with { ErrorMessage = "SECRET123" + new string('x', 1000) }) };
var result = Redactor(opts).Apply(evt);
Assert.DoesNotContain("SECRET123", result.DetailsJson!);
Assert.True(Encoding.UTF8.GetByteCount(Details(result).ErrorMessage!) <= 128);
}
[Fact]
public void EI3_SqlParameterPolicy_DoesNotPersistExceptionEchoedValues()
{
var opts = new AuditLogOptions();
opts.PerTargetOverrides["PrimaryDb"] = new PerTargetRedactionOverride { RedactSqlParamsMatching = "^@token$" };
var evt = NewEvent(channel: AuditChannel.DbOutbound, status: AuditStatus.Failed, outcome: AuditOutcome.Failure,
target: "PrimaryDb.Query", request: "{\"parameters\":{\"@token\":\"SECRET123\"}}", errorDetail: "SqlException duplicate key value is (SECRET123)");
evt = evt with { DetailsJson = AuditDetailsCodec.Serialize(Details(evt) with { ErrorMessage = "duplicate key SECRET123" }) };
var result = Redactor(opts).Apply(evt);
Assert.DoesNotContain("SECRET123", result.DetailsJson!);
Assert.Equal("Failed", Details(result).Status);
}
private static ScadaBridgeAuditRedactor Redactor(
AuditLogOptions? opts = null,
IAuditRedactionFailureCounter? counter = null) =>
@@ -0,0 +1,47 @@
using System.CommandLine;
using System.Net;
using System.Net.Sockets;
using System.Text;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
public class DeploySiteExitCodeTests
{
[Theory]
[InlineData("{\"successCount\":2,\"failureCount\":0}", 0)]
[InlineData("{\"successCount\":1,\"failureCount\":1}", 1)]
[InlineData("{\"unexpected\":true}", 1)]
public async Task DC6_ActualDeployCommandReturnsDomainExitCode(string response, int expected)
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var server = Task.Run(async () =>
{
using var client = await listener.AcceptTcpClientAsync();
await using var stream = client.GetStream();
using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true);
while (await reader.ReadLineAsync() is { Length: > 0 }) { }
var body = Encoding.UTF8.GetBytes(response);
var headers = Encoding.ASCII.GetBytes($"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {body.Length}\r\nConnection: close\r\n\r\n");
await stream.WriteAsync(headers);
await stream.WriteAsync(body);
});
var root = new RootCommand();
var url = new Option<string>("--url") { Recursive = true };
var format = new Option<string>("--format") { Recursive = true };
var user = new Option<string>("--username") { Recursive = true };
var password = new Option<string>("--password") { Recursive = true };
root.Add(url); root.Add(format); root.Add(user); root.Add(password);
root.Add(DeployCommands.Build(url, format, user, password));
var exit = await root.Parse(["deploy", "site", "--site-id", "1", "--url", $"http://127.0.0.1:{port}",
"--username", "test", "--password", "test", "--format", "json"]).InvokeAsync();
await server.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(expected, exit);
}
finally { listener.Stop(); }
}
}
@@ -0,0 +1,41 @@
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests;
/// <summary>Verifies worker publication and termination in the deployed Linux runtime image.</summary>
[Collection("Playwright")]
public class SandboxWorkerDeploymentTests(PlaywrightFixture fixture)
{
[Fact]
public async Task Medium_WorkerRunsBoundsOutputAndRecoversAfterNoncooperativeScript()
{
var page = await fixture.NewAuthenticatedPageAsync();
try
{
async Task<JsonElement> Run(string code) => await page.EvaluateAsync<JsonElement>("""
async code => {
const response = await fetch('/api/script-analysis/run', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, timeoutSeconds: 10 })
});
if (!response.ok) throw new Error(`Test Run HTTP ${response.status}`);
return await response.json();
}
""", code);
var first = await Run("return 42;");
Assert.True(first.GetProperty("success").GetBoolean(), first.ToString());
Assert.Equal("42", first.GetProperty("returnValueJson").GetString());
var bounded = await Run("Console.Write(new string('x', 100000)); return new string('y', 100000);");
Assert.True(bounded.GetProperty("consoleOutput").GetString()!.Length <= 32768 + "\n… (truncated)".Length);
Assert.Contains("truncated", bounded.GetProperty("consoleOutput").GetString());
Assert.True(bounded.GetRawText().Length < 100000);
var timeout = await Run("while (true) { }");
Assert.False(timeout.GetProperty("success").GetBoolean());
Assert.Equal(4, timeout.GetProperty("errorKind").GetInt32());
var recovered = await Run("return 43;");
Assert.True(recovered.GetProperty("success").GetBoolean(), recovered.ToString());
Assert.Equal("43", recovered.GetProperty("returnValueJson").GetString());
}
finally { await page.CloseAsync(); }
}
}
@@ -0,0 +1,35 @@
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.ScriptAnalysis;
[Collection("Sandbox worker processes")]
public class BoundedSandboxOutputTests
{
[Theory]
[InlineData("block")]
[InlineData("new { Data = block }")]
public async Task Worker_OversizedConsoleAndReturn_AreExplicitlyBounded(string returnExpression)
{
using var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection().BuildServiceProvider();
using var cache = new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions());
var runner = new ScriptAnalysisService(NSubstitute.Substitute.For<ISharedScriptCatalog>(), cache, services);
var result = await runner.RunInSandboxAsync(new SandboxRunRequest(
"var block = new string('x', 100000); for (int i=0; i<1000; i++) System.Console.Write(block); return " + returnExpression + ";", null, 10), CancellationToken.None);
Assert.True(result.Success, result.Error);
Assert.True(result.ConsoleOutput.Length < 32100);
Assert.Contains("truncated", result.ConsoleOutput);
Assert.Contains("truncated", result.ReturnValueJson!);
Assert.True(System.Text.Encoding.UTF8.GetByteCount(result.ReturnValueJson!) < 32000);
}
[Fact]
public void ConcurrentRepeatedLargeWrites_RetainOnlyBudget()
{
var writer = new BoundedSandboxWriter(32000);
var block = new string('x', 100000);
Parallel.For(0, 1000, _ => writer.WriteLine(block));
Assert.Equal(32000, writer.RetainedCharacters);
Assert.True(writer.DroppedCharacters >= 99968000);
Assert.True(writer.ToString().Length < 32100);
Assert.Contains("truncated", writer.ToString());
}
}
@@ -0,0 +1,86 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.ScriptAnalysis;
[Collection("Sandbox worker processes")]
public class SandboxWorkerIsolationTests
{
[Fact]
public async Task FourBusyWorkers_RejectQueuedFifthWithoutStartingAnotherProcess()
{
var allEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<ExternalCallResult>(TaskCreationOptions.RunContinuationsAsynchronously);
var count = 0;
var external = Substitute.For<IExternalSystemClient>();
external.CallAsync("probe", "blocked", Arg.Any<IReadOnlyDictionary<string, object?>?>(), Arg.Any<CancellationToken>())
.Returns(_ => { if (Interlocked.Increment(ref count) == 4) allEntered.TrySetResult(); return release.Task; });
using var services = new ServiceCollection().AddSingleton(external).BuildServiceProvider();
var catalog = Substitute.For<ISharedScriptCatalog>();
var workers = Enumerable.Range(0, 4).Select(_ => SandboxWorkerProtocol.RunAsync(new SandboxRunRequest(
"await ExternalSystem.Call(\"probe\", \"blocked\"); return 42;", null, 10), services, catalog, CancellationToken.None)).ToArray();
try
{
await allEntered.Task.WaitAsync(TimeSpan.FromSeconds(8));
var fifthStarted = false;
var fifth = await SandboxWorkerProtocol.RunCoreAsync(new SandboxRunRequest("return 1;", null, 1),
services, catalog, CancellationToken.None, _ => fifthStarted = true);
Assert.Equal(SandboxErrorKind.Timeout, fifth.ErrorKind);
Assert.False(fifthStarted);
}
finally { release.TrySetResult(new ExternalCallResult(true, "null", null)); }
foreach (var result in await Task.WhenAll(workers)) Assert.True(result.Success, result.Error);
}
[Fact]
public async Task UncooperativeBroker_StillKillsChildAtDeadline_AndKeepsLifetimeUntilBrokerCompletes()
{
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<ExternalCallResult>(TaskCreationOptions.RunContinuationsAsynchronously);
var external = Substitute.For<IExternalSystemClient>();
external.CallAsync("probe", "blocked", Arg.Any<IReadOnlyDictionary<string, object?>?>(), Arg.Any<CancellationToken>())
.Returns(_ => { entered.TrySetResult(); return release.Task; });
using var services = new ServiceCollection().AddSingleton(external).BuildServiceProvider();
System.Diagnostics.Process? child = null;
var run = SandboxWorkerProtocol.RunCoreAsync(new SandboxRunRequest(
"await ExternalSystem.Call(\"probe\", \"blocked\"); return 42;", null, 10), services,
Substitute.For<ISharedScriptCatalog>(), CancellationToken.None,
id => child = System.Diagnostics.Process.GetProcessById(id));
try
{
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10));
Assert.NotNull(child);
await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(12));
Assert.False(run.IsCompleted); // Do not dispose the broker's request scope or recycle its charged slot.
}
finally { release.TrySetResult(new ExternalCallResult(true, "null", null)); }
var result = await run.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(SandboxErrorKind.Timeout, result.ErrorKind);
child?.Dispose();
}
[Fact]
public async Task NoncooperativeWorker_IsTerminated_AndCapacityCanBeReused()
{
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var external = Substitute.For<IExternalSystemClient>();
external.CallAsync("probe", "entered", Arg.Any<IReadOnlyDictionary<string, object?>?>(), Arg.Any<CancellationToken>())
.Returns(_ => { entered.TrySetResult(); return new ExternalCallResult(true, "null", null); });
var services = new ServiceCollection().AddSingleton(external).BuildServiceProvider();
using var cache = new MemoryCache(new MemoryCacheOptions());
var runner = new ScriptAnalysisService(Substitute.For<ISharedScriptCatalog>(), cache, services);
var run = runner.RunInSandboxAsync(new SandboxRunRequest(
"await ExternalSystem.Call(\"probe\", \"entered\"); while (true) { }", null, 10), CancellationToken.None);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10));
var result = await run.WaitAsync(TimeSpan.FromSeconds(15));
Assert.Equal(SandboxErrorKind.Timeout, result.ErrorKind);
var next = await runner.RunInSandboxAsync(new SandboxRunRequest("return 42;", null, 10), CancellationToken.None);
Assert.True(next.Success, next.Error);
Assert.Equal("42", next.ReturnValueJson);
Assert.Single(external.ReceivedCalls());
}
}
@@ -4,6 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.ScriptAnalysis;
[Collection("Sandbox worker processes")]
public class ScriptAnalysisServiceTests
{
private readonly ISharedScriptCatalog _catalog = Substitute.For<ISharedScriptCatalog>();
@@ -651,9 +652,9 @@ public class ScriptAnalysisServiceTests
var longTask = RunLong();
var shortTasks = new List<Task<string>>();
for (var round = 0; round < 12; round++)
for (var round = 0; round < 1; round++)
{
for (var k = 0; k < 4; k++)
for (var k = 0; k < 3; k++)
shortTasks.Add(RunShort(round * 4 + k));
await Task.Yield();
}
@@ -284,9 +284,14 @@ public class AuditLogQueryServiceTests
});
await using var provider = services.BuildServiceProvider();
var auth = Substitute.For<Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider>();
auth.GetAuthenticationStateAsync().Returns(Task.FromResult(new Microsoft.AspNetCore.Components.Authorization.AuthenticationState(
new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity(new[] {
new System.Security.Claims.Claim(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.RoleClaimType,
ZB.MOM.WW.ScadaBridge.Security.Roles.Viewer) }, "test")))));
var sut = new AuditLogQueryService(
provider.GetRequiredService<IServiceScopeFactory>(),
EmptyAggregator());
EmptyAggregator(), auth);
await sut.GetExecutionTreeAsync(Guid.NewGuid());
await sut.GetExecutionTreeAsync(Guid.NewGuid());
@@ -0,0 +1,42 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
public class AuditTreeScopeTests
{
[Fact]
public async Task ProductionTreeRead_UsesFreshCallerScope_EveryTime()
{
var repo = Substitute.For<IAuditLogRepository>();
using var provider = new ServiceCollection().AddSingleton(repo).BuildServiceProvider();
var auth = new ChangingAuth();
var service = new AuditLogQueryService(provider.GetRequiredService<IServiceScopeFactory>(),
Substitute.For<ICentralHealthAggregator>(), auth);
var execution = Guid.NewGuid();
auth.Site = "42";
await service.GetExecutionTreeAsync(execution);
auth.Site = "43";
await service.GetExecutionTreeAsync(execution);
await repo.Received(1).GetExecutionTreeAsync(execution, Arg.Any<CancellationToken>(),
Arg.Is<IReadOnlyList<string>?>(x => x != null && x.SequenceEqual(new[] { "42" })));
await repo.Received(1).GetExecutionTreeAsync(execution, Arg.Any<CancellationToken>(),
Arg.Is<IReadOnlyList<string>?>(x => x != null && x.SequenceEqual(new[] { "43" })));
}
private sealed class ChangingAuth : AuthenticationStateProvider
{
public string Site { get; set; } = "42";
public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity(new[] {
new Claim(JwtTokenService.RoleClaimType, Roles.Viewer),
new Claim(JwtTokenService.SiteIdClaimType, Site)
}, "test"))));
}
}
@@ -64,4 +64,5 @@
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-pgww-w46g-26qg" />
</ItemGroup>
<ItemGroup><ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.CentralUI.SandboxWorker/ZB.MOM.WW.ScadaBridge.CentralUI.SandboxWorker.csproj" /></ItemGroup>
</Project>
@@ -0,0 +1,96 @@
using System.Diagnostics;
using System.Reflection;
using System.Runtime.InteropServices;
using Google.Protobuf.Reflection;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
/// <summary>Compiles source descriptors and compares the complete vendored wire contract.</summary>
public class ProtoDescriptorContractTests
{
[Fact]
public async Task XC4_SourceDescriptorsMatchVendoredMessagesAndServices()
{
var compiled = await CompileAsync();
foreach (var generated in Generated())
Assert.Equal(CanonicalGenerated(generated), compiled.File.Single(f => f.Name == generated.Name));
}
[Theory]
[InlineData("string site_identifier = 1;", "string renamed_site = 1;")]
[InlineData("string site_identifier = 1;", "bytes site_identifier = 1;")]
[InlineData("rpc Heartbeat(HeartbeatDto)", "rpc RenamedHeartbeat(HeartbeatDto)")]
public async Task GuardDetectsNameTypeAndServiceDrift(string before, string after)
{
var compiled = await CompileAsync(before, after);
var generated = CentralControlReflection.Descriptor;
Assert.NotEqual(CanonicalGenerated(generated), compiled.File.Single(f => f.Name == generated.Name));
}
// C# embeds descriptors with implicit JSON names omitted; descriptor_set_out expands
// them. Compare effective names so explicit json_name drift remains detectable too.
private static FileDescriptorProto CanonicalGenerated(FileDescriptor file)
{
var proto = file.ToProto();
foreach (var message in proto.MessageType)
FillJsonNames(message, file.MessageTypes.Single(m => m.Name == message.Name));
return proto;
}
private static void FillJsonNames(DescriptorProto proto, MessageDescriptor descriptor)
{
foreach (var field in proto.Field)
field.JsonName = descriptor.FindFieldByNumber(field.Number).JsonName;
foreach (var nested in proto.NestedType)
FillJsonNames(nested, descriptor.NestedTypes.Single(m => m.Name == nested.Name));
}
private static FileDescriptor[] Generated() =>
[SitestreamReflection.Descriptor, SiteCommandReflection.Descriptor, CentralControlReflection.Descriptor];
private static async Task<FileDescriptorSet> CompileAsync(string? before = null, string? after = null)
{
var tools = typeof(ProtoDescriptorContractTests).Assembly.GetCustomAttributes<AssemblyMetadataAttribute>()
.Single(a => a.Key == "GrpcToolsRoot").Value!;
var platform = OperatingSystem.IsMacOS() ? "macosx_x64"
: OperatingSystem.IsWindows() ? (RuntimeInformation.ProcessArchitecture == Architecture.X86 ? "windows_x86" : "windows_x64")
: RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "linux_arm64"
: RuntimeInformation.ProcessArchitecture == Architecture.X86 ? "linux_x86" : "linux_x64";
var executable = Path.Combine(tools, "tools", platform, OperatingSystem.IsWindows() ? "protoc.exe" : "protoc");
var directory = Path.Combine(Path.GetTempPath(), "scadabridge-proto-contract-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path.Combine(directory, "Protos"));
try
{
foreach (var file in Directory.GetFiles(Path.Combine(AppContext.BaseDirectory, "ProtoSources", "Protos"), "*.proto"))
{
var source = await File.ReadAllTextAsync(file);
if (before is not null && Path.GetFileName(file) == "central_control.proto")
{
Assert.Contains(before, source);
source = source.Replace(before, after, StringComparison.Ordinal);
}
await File.WriteAllTextAsync(Path.Combine(directory, "Protos", Path.GetFileName(file)), source);
}
var output = Path.Combine(directory, "descriptor.bin");
var start = new ProcessStartInfo(executable)
{
WorkingDirectory = directory, RedirectStandardError = true, RedirectStandardOutput = true,
UseShellExecute = false, CreateNoWindow = true
};
start.ArgumentList.Add("--proto_path=" + directory);
start.ArgumentList.Add("--proto_path=" + Path.Combine(tools, "build", "native", "include"));
start.ArgumentList.Add("--descriptor_set_out=" + output);
foreach (var file in Directory.GetFiles(Path.Combine(directory, "Protos"), "*.proto").Order())
start.ArgumentList.Add("Protos/" + Path.GetFileName(file));
using var process = Process.Start(start)!;
var stderr = process.StandardError.ReadToEndAsync();
var stdout = process.StandardOutput.ReadToEndAsync();
try { await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30)); }
catch { process.Kill(entireProcessTree: true); await process.WaitForExitAsync(); throw; }
Assert.True(process.ExitCode == 0, await stderr + await stdout);
return FileDescriptorSet.Parser.ParseFrom(await File.ReadAllBytesAsync(output));
}
finally { Directory.Delete(directory, recursive: true); }
}
}
@@ -14,6 +14,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Grpc.Tools" GeneratePathProperty="true" PrivateAssets="All" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
@@ -32,4 +33,12 @@
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="../../src/ZB.MOM.WW.ScadaBridge.Communication/Protos/*.proto"
Link="ProtoSources/Protos/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>GrpcToolsRoot</_Parameter1>
<_Parameter2>$(PkgGrpc_Tools)</_Parameter2>
</AssemblyAttribute>
</ItemGroup>
</Project>
@@ -0,0 +1,98 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
public class MediumPersistenceRegressionTests
{
private static ScadaBridgeDbContext Create(bool protectedWrites = true)
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlite("Data Source=:memory:;Foreign Keys=False").Options;
var db = protectedWrites ? new ScadaBridgeDbContext(options, new EphemeralDataProtectionProvider())
: new ScadaBridgeDbContext(options);
db.Database.OpenConnection();
db.Database.EnsureCreated();
return db;
}
[Fact]
public async Task DL1_UniqueNameCannotExistAtTwoSites()
{
await using var db = Create();
db.Instances.Add(new Instance("shared-pump") { SiteId = 1, TemplateId = 1 });
await db.SaveChangesAsync();
db.Instances.Add(new Instance("shared-pump") { SiteId = 2, TemplateId = 1 });
await Assert.ThrowsAsync<InstanceNameConflictException>(() => db.SaveChangesAsync());
}
[Fact]
public async Task DL2_ConvertersDoNotShareKeysAcrossProviders()
{
await using var connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=:memory:;Foreign Keys=False");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>().UseSqlite(connection).Options;
await using (var first = new ScadaBridgeDbContext(options, new EphemeralDataProtectionProvider()))
{
await first.Database.EnsureCreatedAsync();
first.DataConnections.Add(new DataConnection("key-isolation", "OpcUa", 1) { PrimaryConfiguration = "{}" });
await first.SaveChangesAsync();
}
await using var wrongKey = new ScadaBridgeDbContext(options, new EphemeralDataProtectionProvider());
await Assert.ThrowsAsync<System.Security.Cryptography.CryptographicException>(() =>
wrongKey.DataConnections.SingleAsync(c => c.Name == "key-isolation"));
}
[Fact]
public async Task DL2_LegacyUpgradeProtectsBothColumnsAndIsIdempotent()
{
await using var db = Create();
await db.Database.ExecuteSqlRawAsync("INSERT INTO DataConnections (SiteId, Name, Protocol, PrimaryConfiguration, BackupConfiguration, FailoverRetryCount) VALUES (1, 'legacy', 'OpcUa', {0}, {1}, 3)",
"""{"Password":"primary-secret"}""", """{"Password":"backup-secret"}""");
await DataConnectionSecretUpgrade.UpgradeAsync(db);
await using var command = db.Database.GetDbConnection().CreateCommand();
command.CommandText = "SELECT PrimaryConfiguration FROM DataConnections WHERE Name = 'legacy'";
var first = (string)(await command.ExecuteScalarAsync())!;
Assert.DoesNotContain("primary-secret", first);
await DataConnectionSecretUpgrade.UpgradeAsync(db);
Assert.Equal(first, (string)(await command.ExecuteScalarAsync())!);
var row = await db.DataConnections.SingleAsync(c => c.Name == "legacy");
Assert.Contains("primary-secret", row.PrimaryConfiguration);
Assert.Contains("backup-secret", row.BackupConfiguration);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task DL2_DataConnectionConfigurationIsEncryptedAndRoundTrips(bool backup)
{
await using var db = Create();
const string secret = "{\"Password\":\"medium-secret-never-plaintext\"}";
var connection = new DataConnection("test", "OpcUa", 1);
if (backup) connection.BackupConfiguration = secret; else connection.PrimaryConfiguration = secret;
db.DataConnections.Add(connection);
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
await using var cmd = db.Database.GetDbConnection().CreateCommand();
cmd.CommandText = $"SELECT {(backup ? "BackupConfiguration" : "PrimaryConfiguration")} FROM DataConnections WHERE Id = {connection.Id}";
var raw = Assert.IsType<string>(await cmd.ExecuteScalarAsync());
Assert.DoesNotContain("medium-secret", raw);
var loaded = await db.DataConnections.SingleAsync(c => c.Id == connection.Id);
Assert.Equal(secret, backup ? loaded.BackupConfiguration : loaded.PrimaryConfiguration);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task DL2_SchemaOnlyContextCannotWriteProtocolCredentials(bool backup)
{
await using var db = Create(false);
var connection = new DataConnection("test", "OpcUa", 1);
if (backup) connection.BackupConfiguration = "{}"; else connection.PrimaryConfiguration = "{}";
db.DataConnections.Add(connection);
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => db.SaveChangesAsync());
Assert.Contains("Data Protection", error.Message);
}
}
@@ -0,0 +1,77 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations.Operations;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
public class MediumPersistenceSqlTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public MediumPersistenceSqlTests(MsSqlMigrationFixture fixture) => _fixture = fixture;
[SkippableFact]
public async Task RealSql_GlobalNameRaceAndLegacyCredentialUpgrade()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>().UseSqlServer(_fixture.ConnectionString).Options;
var protection = new EphemeralDataProtectionProvider();
await using var db = new ScadaBridgeDbContext(options, protection);
var a = new Site("Medium A", "medium-a"); var b = new Site("Medium B", "medium-b");
var template = new Template("Medium template");
db.AddRange(a, b, template);
await db.SaveChangesAsync();
async Task<bool> Insert(int site)
{
await using var attempt = new ScadaBridgeDbContext(options, protection);
attempt.Instances.Add(new Instance("global-medium-pump") { SiteId = site, TemplateId = template.Id });
try { await attempt.SaveChangesAsync(); return true; }
catch (InstanceNameConflictException) { return false; }
}
var race = await Task.WhenAll(Insert(a.Id), Insert(b.Id));
Assert.Single(race, success => success);
Assert.Single(race, success => !success);
await db.Database.ExecuteSqlRawAsync(
"INSERT INTO DataConnections (SiteId, Name, Protocol, PrimaryConfiguration, BackupConfiguration, FailoverRetryCount) VALUES ({0}, 'legacy-medium', 'OpcUa', {1}, {2}, 3)",
a.Id, """{"password":"sql-primary-secret"}""", """{"password":"sql-backup-secret"}""");
await DataConnectionSecretUpgrade.UpgradeAsync(db);
await DataConnectionSecretUpgrade.UpgradeAsync(db);
var protectedRow = await db.DataConnections.SingleAsync(c => c.Name == "legacy-medium");
Assert.Contains("sql-primary-secret", protectedRow.PrimaryConfiguration);
Assert.Contains("sql-backup-secret", protectedRow.BackupConfiguration);
using var connection = _fixture.OpenConnection();
using var raw = connection.CreateCommand();
raw.CommandText = "SELECT PrimaryConfiguration FROM DataConnections WHERE Name = 'legacy-medium'";
Assert.DoesNotContain("sql-primary-secret", (string)(await raw.ExecuteScalarAsync())!);
raw.CommandText = "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='DataConnections' AND COLUMN_NAME IN ('PrimaryConfiguration','BackupConfiguration') AND CHARACTER_MAXIMUM_LENGTH=-1";
Assert.Equal(2, (int)(await raw.ExecuteScalarAsync())!);
// A pre-upgrade conflict is rejected explicitly; the transaction restores the index.
using var transaction = connection.BeginTransaction();
using var legacy = connection.CreateCommand();
legacy.Transaction = transaction;
legacy.CommandText = "DROP INDEX IX_Instances_UniqueName ON Instances";
await legacy.ExecuteNonQueryAsync();
legacy.CommandText = "INSERT INTO Instances (UniqueName, TemplateId, SiteId, State) VALUES ('global-medium-pump', @template, @site, 'NotDeployed')";
legacy.Parameters.AddWithValue("@template", template.Id);
legacy.Parameters.AddWithValue("@site", a.Id);
await legacy.ExecuteNonQueryAsync();
var builder = new MigrationBuilder("Microsoft.EntityFrameworkCore.SqlServer");
new ProbeMigration().Apply(builder);
legacy.CommandText = builder.Operations.OfType<SqlOperation>().First().Sql;
var denied = await Assert.ThrowsAsync<SqlException>(() => legacy.ExecuteNonQueryAsync());
Assert.Equal(51002, denied.Number);
transaction.Rollback();
}
private sealed class ProbeMigration : EnforceGlobalInstanceNamesAndProtectProtocolConfiguration
{
public void Apply(MigrationBuilder builder) => base.Up(builder);
}
}
@@ -22,6 +22,48 @@ public class AuditLogRepositoryTraceEventsTests : IClassFixture<MsSqlMigrationFi
private ScadaBridgeDbContext CreateContext() => new(
new DbContextOptionsBuilder<ScadaBridgeDbContext>().UseSqlServer(_fixture.ConnectionString).Options);
[SkippableFact]
public async Task ScopedTreeAndReplay_ExcludeOtherSite_AndRetainCentralRows()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var root = Guid.NewGuid(); var childA = Guid.NewGuid(); var childB = Guid.NewGuid();
var central = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered, executionId: root);
var allowed = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, executionId: childA, parentExecutionId: root, sourceSiteId: "site-a");
var denied = ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Delivered, executionId: childB, parentExecutionId: root, sourceSiteId: "site-b", responseSummary: "sensitive-sql-values");
await using var context = CreateContext(); var repo = new AuditLogRepository(context);
foreach (var row in new[] { central, allowed, denied }) await repo.InsertIfNotExistsAsync(row);
var tree = await repo.GetExecutionTreeAsync(childA, permittedSiteIds: new[] { "site-a" });
Assert.Contains(tree, node => node.ExecutionId == root);
Assert.DoesNotContain(tree, node => node.ExecutionId == childB);
var replay = await repo.GetTraceEventsAsync(childA, permittedSiteIds: new[] { "site-a" });
Assert.Equal(new[] { central.EventId, allowed.EventId }.Order(), replay.Select(e => e.EventId).Order());
Assert.DoesNotContain(replay, row => row.DetailsJson?.Contains("sensitive-sql-values") == true);
}
[SkippableTheory]
[InlineData(600, 0)]
[InlineData(200, 10000)]
[InlineData(1, 1100000)]
public async Task Replay_BoundsRowsAndUtf8Bytes_AndKeepsRoot(int count, int payloadLength)
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var root = Guid.NewGuid(); var start = DateTime.UtcNow;
await using var context = CreateContext(); var repo = new AuditLogRepository(context);
for (var i = 0; i < count; i++)
await repo.InsertIfNotExistsAsync(ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace,
AuditKind.ScriptRun, AuditStatus.Delivered, executionId: root, occurredAtUtc: payloadLength == 0 ? start : start.AddMilliseconds(i),
responseSummary: new string('界', payloadLength)));
var rows = await repo.GetTraceEventsAsync(root);
var batch = Assert.IsType<TraceEventBatch>(rows);
Assert.True(batch.Truncated);
Assert.Equal(root, batch.RootExecutionId);
Assert.True(batch.Count <= 500);
if (payloadLength == 0) Assert.Equal(500, batch.Count);
if (payloadLength > 1024 * 1024) Assert.Empty(batch);
Assert.Equal(batch.Count, batch.Select(e => e.EventId).Distinct().Count());
Assert.True(batch.Sum(e => System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(e).Length) <= 1024 * 1024);
}
[SkippableFact]
public async Task ReturnsInboundPlusChainTraceRows_Ordered_FromAnyEntry()
{
@@ -244,4 +244,102 @@ public class HighReviewRegressionTests : TestKit
b.ExpectMsg<NativeAlarmTransitionUpdate>();
a.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
[Theory]
[InlineData("OpcUa")]
[InlineData("MxGateway")]
public void DC5_SecondSubscriberReceivesStandingAlarmSnapshot(string protocol)
{
var adapter = Adapter();
AlarmTransitionCallback? callback = null;
((IAlarmSubscribableConnection)adapter).SubscribeAlarmsAsync(Arg.Any<string>(), Arg.Any<string?>(),
Arg.Do<AlarmTransitionCallback>(c => callback = c), Arg.Any<CancellationToken>()).Returns("alarm-handle");
var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor("conn", adapter, new DataConnectionOptions(),
_health, Substitute.For<IDataConnectionFactory>(), protocol)));
var a = CreateTestProbe(); var b = CreateTestProbe();
actor.Tell(new SubscribeAlarmsRequest("a", "a", "conn", "A", null, DateTimeOffset.UtcNow), a.Ref);
a.ExpectMsg<SubscribeAlarmsResponse>();
var alarm = new NativeAlarmTransition("A.Hi", "A", "Limit", AlarmTransitionKind.Snapshot,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), "", "", "", "", "", null, DateTimeOffset.UtcNow, "", "");
if (protocol == "OpcUa")
{
alarm = alarm with { SnapshotSourceReference = "A", SnapshotId = Guid.NewGuid() };
callback!(alarm with { SourceReference = "", Kind = AlarmTransitionKind.SnapshotStart });
a.ExpectMsg<NativeAlarmTransitionUpdate>();
}
callback!(alarm);
callback!(alarm with { SourceReference = "", Kind = AlarmTransitionKind.SnapshotComplete });
a.ExpectMsg<NativeAlarmTransitionUpdate>(); a.ExpectMsg<NativeAlarmTransitionUpdate>();
if (protocol == "OpcUa")
{
// A duplicate end must not erase the DCL replay cache either.
callback!(alarm with { SourceReference = "", Kind = AlarmTransitionKind.SnapshotComplete });
a.ExpectMsg<NativeAlarmTransitionUpdate>();
}
actor.Tell(new SubscribeAlarmsRequest("b", "b", "conn", "A", null, DateTimeOffset.UtcNow), b.Ref);
var received = b.ReceiveN(4, TimeSpan.FromSeconds(2));
Assert.Contains(received.OfType<NativeAlarmTransitionUpdate>(), m => m.Transition.Kind == AlarmTransitionKind.Snapshot && m.Transition.SourceReference == "A.Hi");
Assert.Contains(received.OfType<NativeAlarmTransitionUpdate>(), m => m.Transition.Kind == AlarmTransitionKind.SnapshotComplete);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void DC5_MidSnapshotSubscriberReceivesAlreadySeenPrefix(bool scoped)
{
var adapter = Adapter();
AlarmTransitionCallback? callback = null;
((IAlarmSubscribableConnection)adapter).SubscribeAlarmsAsync(Arg.Any<string>(), Arg.Any<string?>(),
Arg.Do<AlarmTransitionCallback>(c => callback = c), Arg.Any<CancellationToken>()).Returns("alarm-handle");
var actor = Actor(adapter);
var a = CreateTestProbe(); var b = CreateTestProbe();
actor.Tell(new SubscribeAlarmsRequest("a", "a", "conn", "A", null, DateTimeOffset.UtcNow), a.Ref);
a.ExpectMsg<SubscribeAlarmsResponse>();
var alarm = new NativeAlarmTransition("A.Hi", "A", "Limit", AlarmTransitionKind.Snapshot,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), "", "", "", "", "", null, DateTimeOffset.UtcNow, "", "",
SnapshotSourceReference: scoped ? "A" : null, SnapshotId: scoped ? Guid.NewGuid() : null);
if (scoped)
{
callback!(alarm with { SourceReference = "", Kind = AlarmTransitionKind.SnapshotStart });
a.ExpectMsg<NativeAlarmTransitionUpdate>();
}
callback!(alarm);
a.ExpectMsg<NativeAlarmTransitionUpdate>();
actor.Tell(new SubscribeAlarmsRequest("b", "b", "conn", "A", null, DateTimeOffset.UtcNow), b.Ref);
var prefix = b.ReceiveN(scoped ? 3 : 2, TimeSpan.FromSeconds(2));
Assert.Contains(prefix.OfType<NativeAlarmTransitionUpdate>(), m => m.Transition.SourceReference == "A.Hi");
callback!(alarm with { SourceReference = "", Kind = AlarmTransitionKind.SnapshotComplete });
var end = b.ExpectMsg<NativeAlarmTransitionUpdate>();
Assert.Equal(AlarmTransitionKind.SnapshotComplete, end.Transition.Kind);
Assert.Equal(alarm.SnapshotId, end.Transition.SnapshotId);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void DC5_ReplayRejectsOldEpochRowsAndPreservesNewerLiveClear(bool staleEpoch)
{
var adapter = Adapter();
AlarmTransitionCallback? callback = null;
((IAlarmSubscribableConnection)adapter).SubscribeAlarmsAsync(Arg.Any<string>(), Arg.Any<string?>(),
Arg.Do<AlarmTransitionCallback>(c => callback = c), Arg.Any<CancellationToken>()).Returns("alarm-handle");
var actor = Actor(adapter);
var a = CreateTestProbe(); var b = CreateTestProbe();
actor.Tell(new SubscribeAlarmsRequest("a", "a", "conn", "A", null, DateTimeOffset.UtcNow), a.Ref);
a.ExpectMsg<SubscribeAlarmsResponse>();
var alarm = new NativeAlarmTransition("A.Hi", "A", "Limit", AlarmTransitionKind.Snapshot,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), "", "", "", "", "", null, DateTimeOffset.UtcNow, "", "",
SnapshotSourceReference: "A", SnapshotId: Guid.NewGuid());
callback!(alarm with { SourceReference = "", Kind = AlarmTransitionKind.SnapshotStart });
if (staleEpoch) callback!(alarm with { SourceReference = "A.Old", SnapshotId = Guid.NewGuid() });
callback!(alarm);
if (!staleEpoch) callback!(alarm with { Kind = AlarmTransitionKind.Clear, Condition = alarm.Condition with { Active = false, Acknowledged = true }, TransitionTime = alarm.TransitionTime.AddSeconds(1) });
callback!(alarm with { SourceReference = "", Kind = AlarmTransitionKind.SnapshotComplete });
a.ReceiveN(4, TimeSpan.FromSeconds(2));
actor.Tell(new SubscribeAlarmsRequest("b", "b", "conn", "A", null, DateTimeOffset.UtcNow), b.Ref);
var replay = b.ReceiveN(staleEpoch ? 4 : 3, TimeSpan.FromSeconds(2));
var rows = replay.OfType<NativeAlarmTransitionUpdate>().Where(m => m.Transition.Kind == AlarmTransitionKind.Snapshot).ToList();
if (staleEpoch) Assert.Equal("A.Hi", Assert.Single(rows).Transition.SourceReference);
else Assert.Empty(rows);
}
}
@@ -101,7 +101,7 @@ public class MxGatewayBatchSeamTests
}
[Fact]
public async Task AlarmStream_OpensOnTheLongestCommonPrefix_AndOnlyRestartsForAnEscapingSource()
public async Task AlarmStream_UsesLongestCommonPrefix_AndRefreshesEveryNewSource()
{
var fake = new FakeMxGatewayClient();
var adapter = NewAdapter(fake);
@@ -119,15 +119,15 @@ public class MxGatewayBatchSeamTests
await WaitForPrefixCountAsync(fake, 2);
Assert.Equal("Area1.Tank", fake.AlarmStreamPrefixes[1]);
// A source ALREADY covered by the live prefix must NOT restart the stream.
// A covered new source still needs its standing-state snapshot.
await adapter.SubscribeAlarmsAsync("Area1.Tank3.Sub", null, NoTransition);
await Task.Delay(100);
Assert.Equal(2, fake.AlarmStreamPrefixes.Count);
await WaitForPrefixCountAsync(fake, 3);
Assert.Equal("Area1.Tank", fake.AlarmStreamPrefixes[2]);
// A source outside the prefix widens it again — here down to gateway-wide.
await adapter.SubscribeAlarmsAsync("Zone9.Pump", null, NoTransition);
await WaitForPrefixCountAsync(fake, 3);
Assert.Null(fake.AlarmStreamPrefixes[2]); // "" → gateway-wide
await WaitForPrefixCountAsync(fake, 4);
Assert.Null(fake.AlarmStreamPrefixes[3]); // "" → gateway-wide
}
[Fact]
@@ -25,6 +25,46 @@ public class MxGatewayDataConnectionAlarmReconnectTests
["ApiKey"] = "key",
};
[Fact]
public async Task DC5_EachTransportRefreshHasItsOwnConnectionWideEpoch()
{
var clients = new List<PerInstanceFakeMxGatewayClient>();
var adapter = new MxGatewayDataConnection(new RecordingFactory(clients), NullLogger<MxGatewayDataConnection>.Instance);
var received = new System.Collections.Concurrent.ConcurrentQueue<NativeAlarmTransition>();
await adapter.ConnectAsync(Details());
await adapter.SubscribeAlarmsAsync("Area1", null, received.Enqueue);
await WaitUntilAsync(() => clients[0].AlarmStreamOpens.Count == 1);
clients[0].EmitAlarm(MakeTransition(AlarmTransitionKind.SnapshotComplete));
clients[0].EmitAlarm(MakeTransition(AlarmTransitionKind.SnapshotStart));
clients[0].EmitAlarm(MakeTransition(AlarmTransitionKind.SnapshotComplete));
var starts = received.Where(t => t.Kind == AlarmTransitionKind.SnapshotStart).ToArray();
var ends = received.Where(t => t.Kind == AlarmTransitionKind.SnapshotComplete).ToArray();
Assert.Equal(2, starts.Length);
Assert.Equal(2, ends.Length);
Assert.NotNull(starts[0].SnapshotId);
Assert.NotEqual(starts[0].SnapshotId, starts[1].SnapshotId);
Assert.Equal(starts[0].SnapshotId, ends[0].SnapshotId);
Assert.Equal(starts[1].SnapshotId, ends[1].SnapshotId);
Assert.All(starts, t => Assert.Null(t.SnapshotSourceReference));
await adapter.DisconnectAsync();
}
[Fact]
public async Task DC5_CoveredNewSourceRequestsFreshStandingSnapshot()
{
var clients = new List<PerInstanceFakeMxGatewayClient>();
var adapter = new MxGatewayDataConnection(new RecordingFactory(clients), NullLogger<MxGatewayDataConnection>.Instance);
await adapter.ConnectAsync(Details());
await adapter.SubscribeAlarmsAsync("Area1", null, _ => { });
await WaitUntilAsync(() => clients[0].AlarmStreamOpens.Count == 1);
await adapter.SubscribeAlarmsAsync("Area1.Tank1", null, _ => { });
await WaitUntilAsync(() => clients[0].AlarmStreamOpens.Count == 2);
Assert.True(clients[0].AlarmStreamTokens[0].IsCancellationRequested);
await adapter.DisconnectAsync();
}
[Fact]
public async Task Reconnect_ThenSubscribeAlarms_OpensNewStreamOnNewClient()
{
@@ -78,7 +118,7 @@ public class MxGatewayDataConnectionAlarmReconnectTests
var received = new List<NativeAlarmTransition>();
await adapter.ConnectAsync(Details());
await adapter.SubscribeAlarmsAsync("Area1.Tank1", null, t => received.Add(t));
await adapter.SubscribeAlarmsAsync("Area1.Tank1", null, t => { if (t.Kind != AlarmTransitionKind.SnapshotStart) received.Add(t); });
await WaitUntilAsync(() => clients[0].AlarmStreamOpens.Count == 1);
// Prove the feed works pre-reconnect.
@@ -86,7 +126,7 @@ public class MxGatewayDataConnectionAlarmReconnectTests
await WaitUntilAsync(() => received.Count == 1);
await adapter.ConnectAsync(Details()); // reconnect
await adapter.SubscribeAlarmsAsync("Area1.Tank1", null, t => received.Add(t));
await adapter.SubscribeAlarmsAsync("Area1.Tank1", null, t => { if (t.Kind != AlarmTransitionKind.SnapshotStart) received.Add(t); });
await WaitUntilAsync(() => clients[1].AlarmStreamOpens.Count == 1);
// A transition on the OLD (disposed, orphaned) client must never reach the
@@ -0,0 +1,23 @@
using Opc.Ua;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
public class OpcUaSecurityDowngradeTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public void DC4_RequestedProtectionNeverFallsBackToNone(bool discoveryFails)
{
Assert.Throws<InvalidOperationException>(() => RealOpcUaClient.DiscoverEndpoint("opc.tcp://test:4840",
MessageSecurityMode.SignAndEncrypt, () => discoveryFails
? throw new Exception("discovery unavailable")
: new EndpointDescriptionCollection { new("opc.tcp://test:4840") { SecurityMode = MessageSecurityMode.None } }));
}
[Fact]
public void DC4_ExactRequestedModeIsSelected()
{
var encrypted = new EndpointDescription("opc.tcp://test:4840") { SecurityMode = MessageSecurityMode.SignAndEncrypt };
Assert.Same(encrypted, RealOpcUaClient.DiscoverEndpoint("opc.tcp://test:4840", MessageSecurityMode.SignAndEncrypt,
() => new EndpointDescriptionCollection { new("opc.tcp://test:4840"), encrypted }));
}
}
@@ -6,6 +6,37 @@ namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests;
public class HttpRecipeSenderTests
{
[Fact]
public async Task DC7_ExplicitStandbyRefusal_TriesSecondUrl_ButGeneric503DoesNot()
{
foreach (var standby in new[] { true, false })
{
var calls = 0;
var handler = new StubHandler(_ =>
{
calls++;
if (calls > 1) return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"Result\":true}") };
var result = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) { Content = new StringContent("{\"code\":\"STANDBY_NODE\"}") };
if (standby) result.Headers.Add("X-ScadaBridge-Standby", "1");
return result;
});
using var http = new HttpClient(handler);
var outcome = await Notifier.RunAsync(["http://a", "http://b"], Payload, new HttpRecipeSender(http, "key"), CancellationToken.None);
Assert.Equal(standby, outcome.Ok);
Assert.Equal(standby ? 2 : 1, calls);
}
}
[Fact]
public async Task DC7_AppliedThenResponseLost_DoesNotReplay()
{
var effects = 0;
using var http = new HttpClient(new StubHandler(_ => { effects++; throw new HttpRequestException("lost response"); }));
var outcome = await Notifier.RunAsync(["http://a", "http://b"], Payload, new HttpRecipeSender(http, "key"), CancellationToken.None);
Assert.Equal(1, effects);
Assert.Contains("OUTCOME_UNKNOWN", outcome.Reason);
}
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
@@ -23,7 +54,7 @@ public class HttpRecipeSenderTests
[Fact]
public async Task ConnectFailure_maps_to_ConnectFailed()
{
var handler = new StubHandler(_ => throw new HttpRequestException("refused"));
var handler = new StubHandler(_ => throw new HttpRequestException("refused", new System.Net.Sockets.SocketException((int)System.Net.Sockets.SocketError.ConnectionRefused)));
using var http = new HttpClient(handler);
var sender = new HttpRecipeSender(http, "sbk_x");
var outcome = await sender.SendAsync("http://a", Payload, CancellationToken.None);
@@ -299,15 +299,19 @@ public class DeploySiteAsyncTests : TestKit
new FlatteningPipelineResult(config, "sha256:3", ValidationResult.Success()));
});
var tracker = new ConcurrencyTracker();
var commActor = Sys.ActorOf(Props.Create(() =>
new ThrottledSiteActor(new ConcurrencyTracker(), slowInstanceName: null, slowDelay: TimeSpan.Zero)));
new ThrottledSiteActor(tracker, slowInstanceName: null, slowDelay: TimeSpan.Zero)));
var service = CreateService(commActor, maxParallelism: 2);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => service.DeploySiteAsync(SiteId, "admin", cancellationToken: cts.Token));
var result = await service.DeploySiteAsync(SiteId, "admin", cancellationToken: cts.Token);
Assert.True(result.IsSuccess);
Assert.Equal(6, result.Value.FailureCount);
Assert.Equal(0, result.Value.SuccessCount);
Assert.Equal(0, _lockManager.TrackedLockCount);
Assert.Equal(0, tracker.MaxObserved); // No command may dispatch after the preparation deadline.
// No prepared deployment may be left InProgress.
await _repo.Received().UpdateDeploymentRecordAsync(
@@ -52,7 +52,18 @@ public class StaleInstanceProbeTests
private StaleInstanceProbe CreateProbe() => new(_pipeline, _watermark);
[Fact]
public async Task UnchangedWatermark_ServesMemoisedHash_WithoutReflattening()
public async Task PeerEdit_WithIndependentWatermark_IsObservedByWarmActiveProbe()
{
var active = CreateProbe();
var peer = new TemplateGraphWatermark();
Assert.Equal("sha256:first", await active.GetCurrentRevisionHashAsync(InstanceId));
_currentHash = "sha256:peer-edit"; // Shared database changed through another central.
peer.BumpAll();
Assert.Equal("sha256:peer-edit", await active.GetCurrentRevisionHashAsync(InstanceId));
}
[Fact]
public async Task UnchangedWatermark_StillReflattensAuthoritatively()
{
var probe = CreateProbe();
@@ -60,7 +71,7 @@ public class StaleInstanceProbeTests
Assert.Equal(1, _flattenCount);
Assert.Equal("sha256:first", await probe.GetCurrentRevisionHashAsync(InstanceId));
Assert.Equal(1, _flattenCount);
Assert.Equal(2, _flattenCount);
}
/// <summary>
@@ -77,13 +88,13 @@ public class StaleInstanceProbeTests
// The underlying config drifted (e.g. a data connection was repointed).
_currentHash = "sha256:second";
// Without a bump the stale hash would still be served...
Assert.Equal("sha256:first", await probe.GetCurrentRevisionHashAsync(InstanceId));
// Even without a local bump, a peer edit is authoritative.
Assert.Equal("sha256:second", await probe.GetCurrentRevisionHashAsync(InstanceId));
_watermark.BumpAll();
Assert.Equal("sha256:second", await probe.GetCurrentRevisionHashAsync(InstanceId));
Assert.Equal(2, _flattenCount);
Assert.Equal(3, _flattenCount);
}
[Fact]
@@ -0,0 +1,72 @@
using System.Net;
using Grpc.Net.Client;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
public class CentralListenerIsolationTests
{
[Fact]
public async Task XC1_ActualSocketOwnsEndpoint_EvenWithForgedAuthority()
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.ConfigureKestrel(k =>
{
for (var i = 0; i < 3; i++) k.Listen(IPAddress.Loopback, 0, l => l.Protocols = HttpProtocols.Http2);
});
builder.Services.AddGrpc();
await using var app = builder.Build();
var grpcPort = 0; var httpPort = 0; var metricsPort = 0;
app.UseRouting();
app.Use((context, next) =>
{
var branch = ((IApplicationBuilder)app).New();
branch.UseCentralListenerIsolation(grpcPort, metricsPort, [httpPort]);
branch.Run(next);
return branch.Build()(context);
});
app.MapGet("/metrics", () => "metrics");
app.MapGet("/health/ready", () => "ready");
app.MapGet("/management", () => "must stay on HTTP listener");
app.MapGrpcService<EchoCentral>();
await app.StartAsync();
var addresses = app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.ToArray();
httpPort = new Uri(addresses[0]).Port; grpcPort = new Uri(addresses[1]).Port; metricsPort = new Uri(addresses[2]).Port;
using var client = new HttpClient();
async Task<HttpStatusCode> Get(string address, string path, int authority)
{
using var request = new HttpRequestMessage(HttpMethod.Get, address + path)
{ Version = HttpVersion.Version20, VersionPolicy = HttpVersionPolicy.RequestVersionExact };
request.Headers.Host = $"127.0.0.1:{authority}";
using var response = await client.SendAsync(request);
return response.StatusCode;
}
Assert.Equal(HttpStatusCode.NotFound, await Get(addresses[1], "/metrics", httpPort));
Assert.Equal(HttpStatusCode.NotFound, await Get(addresses[1], "/management", httpPort));
Assert.Equal(HttpStatusCode.OK, await Get(addresses[0], "/management", grpcPort));
Assert.Equal(HttpStatusCode.OK, await Get(addresses[2], "/metrics", httpPort));
Assert.Equal(HttpStatusCode.OK, await Get(addresses[2], "/health/ready", grpcPort));
Assert.Equal(HttpStatusCode.NotFound, await Get(addresses[2], "/management", httpPort));
using var good = GrpcChannel.ForAddress(addresses[1]);
await new CentralControlService.CentralControlServiceClient(good).HeartbeatAsync(new HeartbeatDto());
using var inverseClient = new HttpClient();
inverseClient.DefaultRequestHeaders.Host = $"127.0.0.1:{grpcPort}";
using var wrong = GrpcChannel.ForAddress(addresses[0], new GrpcChannelOptions { HttpClient = inverseClient });
var rejected = await Assert.ThrowsAsync<RpcException>(async () =>
await new CentralControlService.CentralControlServiceClient(wrong).HeartbeatAsync(new HeartbeatDto()));
Assert.Equal(StatusCode.Unimplemented, rejected.StatusCode);
}
public sealed class EchoCentral : CentralControlService.CentralControlServiceBase
{
public override Task<Empty> Heartbeat(HeartbeatDto request, ServerCallContext context) => Task.FromResult(new Empty());
}
}
@@ -0,0 +1,61 @@
using System.Net;
using System.Text;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.InboundAPI;
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
public class InboundBodyLimitKestrelTests
{
[Theory]
[InlineData(false, false, false)]
[InlineData(false, true, false)]
[InlineData(true, false, false)]
[InlineData(true, true, false)]
[InlineData(false, false, true)]
public async Task EI10_OverLimitBody_AuditCannotDisableKestrelCap(bool http2, bool skipCapture, bool declared)
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.ConfigureKestrel(o => o.Listen(IPAddress.Loopback, 0, l => l.Protocols = http2 ? HttpProtocols.Http2 : HttpProtocols.Http1));
builder.Services.Configure<InboundApiOptions>(o => o.MaxRequestBodyBytes = 128);
builder.Services.Configure<AuditLogOptions>(o => o.PerTargetOverrides["echo"] = new PerTargetRedactionOverride { SkipBodyCapture = skipCapture });
builder.Services.AddSingleton(Substitute.For<ICentralAuditWriter>());
await using var app = builder.Build();
app.UseRouting();
app.UseAuditWriteMiddleware();
var dispatched = 0;
app.MapPost("/api/{methodName}", async (HttpContext context) =>
{
await context.Request.Body.CopyToAsync(Stream.Null);
Interlocked.Increment(ref dispatched);
return Results.Ok();
}).AddEndpointFilter<InboundApiEndpointFilter>();
await app.StartAsync();
var address = app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.Single();
using var client = new HttpClient();
using var request = new HttpRequestMessage(HttpMethod.Post, address + "/api/echo")
{ Version = http2 ? HttpVersion.Version20 : HttpVersion.Version11, VersionPolicy = HttpVersionPolicy.RequestVersionExact };
var bytes = Encoding.UTF8.GetBytes("{\"value\":\"" + new string('x', 1000) + "\"}");
request.Content = declared ? new ByteArrayContent(bytes) : new UnknownLengthContent(bytes);
request.Content.Headers.ContentType = new("application/json");
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, response.StatusCode);
Assert.Equal(0, dispatched);
}
private sealed class UnknownLengthContent(byte[] bytes) : HttpContent
{
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => stream.WriteAsync(bytes).AsTask();
protected override bool TryComputeLength(out long length) { length = 0; return false; }
}
}
@@ -1,4 +1,5 @@
using System.Net;
using Microsoft.Extensions.DependencyInjection;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
@@ -24,6 +25,74 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.Middleware;
/// </summary>
public class AuditWriteMiddlewareTests
{
[Theory]
[InlineData(0, 1)]
[InlineData(1, 1)]
[InlineData(2, 0)]
[InlineData(3, 0)]
public async Task EI1_RelayAuditOwnership_OnlyServingPeerSuppressesLocalFailure(int scenario, int localRows)
{
IResult forwarded = scenario switch
{
0 => Results.Json(new { code = "ACTIVE_NODE_UNREACHABLE" }, statusCode: 503),
1 => Results.Json(new { code = "STANDBY_NODE" }, statusCode: 503),
_ => new HttpStandbyRequestForwarder.RelayedResult(scenario == 2 ? 200 : 500, "application/json", "{}"u8.ToArray())
};
var services = new ServiceCollection().AddLogging();
services.AddSingleton<IActiveNodeGate>(new StandbyGate());
services.AddSingleton<IStandbyRequestForwarder>(new ResultForwarder(forwarded));
using var provider = services.BuildServiceProvider();
var writer = new RecordingAuditWriter();
var middleware = CreateMiddleware(async ctx =>
{
var filter = new InboundApiEndpointFilter(NullLogger<InboundApiEndpointFilter>.Instance,
new InboundOptions(new InboundApiOptions()));
var result = await filter.InvokeAsync(EndpointFilterInvocationContext.Create(ctx), _ => throw new InvalidOperationException("Must not execute locally"));
await ((IResult)result!).ExecuteAsync(ctx);
}, writer);
var context = BuildContext("echo", "{}");
context.RequestServices = provider;
context.Response.Body = new MemoryStream();
await middleware.InvokeAsync(context);
Assert.Equal(localRows, writer.Events.Count);
if (localRows > 0) Assert.NotNull(writer.Events[0].ExecutionId);
}
private sealed class StandbyGate : IActiveNodeGate { public bool IsActiveNode => false; }
private sealed class ResultForwarder(IResult result) : IStandbyRequestForwarder
{
public bool IsConfigured => true;
public Task<IResult> ForwardAsync(HttpContext context, CancellationToken cancellationToken) => Task.FromResult(result);
}
private sealed class InboundOptions(InboundApiOptions value) : IOptionsMonitor<InboundApiOptions>
{
public InboundApiOptions CurrentValue => value;
public InboundApiOptions Get(string? name) => value;
public IDisposable? OnChange(Action<InboundApiOptions, string?> listener) => null;
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task EI2_SkipBodyCapture_SuppressesExceptionText(bool escaped)
{
var writer = new RecordingAuditWriter();
var opts = new AuditLogOptions();
opts.PerTargetOverrides["secret"] = new PerTargetRedactionOverride { SkipBodyCapture = true };
var middleware = CreateMiddleware(ctx =>
{
ctx.Items[AuditWriteMiddleware.ErrorDetailItemKey] = "payload SECRET123";
if (escaped) throw new ArgumentException("payload SECRET123");
ctx.Response.StatusCode = 500;
return Task.CompletedTask;
}, writer, opts);
var context = BuildContext("secret", "SECRET123");
if (escaped) await Assert.ThrowsAsync<ArgumentException>(() => middleware.InvokeAsync(context));
else await middleware.InvokeAsync(context);
var row = Assert.Single(writer.Events);
Assert.DoesNotContain("SECRET123", System.Text.Json.JsonSerializer.Serialize(row));
}
/// <summary>
/// Test-only recording <see cref="ICentralAuditWriter"/>. Captures every
/// <see cref="AuditEvent"/> the middleware emits so each test can assert on
@@ -0,0 +1,152 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
public class RelayProvenanceTests
{
[Theory]
[InlineData("none")]
[InlineData("body")]
[InlineData("path")]
[InlineData("credential")]
[InlineData("expired")]
[InlineData("wrongkey")]
public async Task EI5_GenuineRelayPreservesCallerIp_OnlyForIntactSignedRequest(string tamper)
{
var options = new Monitor<InboundApiOptions>(new() { ApiKeyPepper = new string('p', 32), StandbyForwardPeerEndpoints = ["http://peer"] });
var writer = new Writer();
var handler = new Handler(async request =>
{
var active = Context(tamper == "body" ? "changed" : await request.Content!.ReadAsStringAsync(), "10.0.0.2");
foreach (var header in request.Headers) active.Request.Headers[header.Key] = header.Value.ToArray();
active.Request.ContentType = request.Content!.Headers.ContentType?.ToString();
if (tamper == "path") active.Request.Path = "/api/another";
if (tamper == "credential") active.Request.Headers["X-API-Key"] = "changed-key";
if (tamper is "expired" or "wrongkey")
{
var envelope = active.Request.Headers["X-ScadaBridge-Relay"].ToString().Split('.');
var node = System.Text.Json.Nodes.JsonNode.Parse(Convert.FromBase64String(envelope[0]))!;
if (tamper == "expired") node["Time"] = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds();
var bytes = JsonSerializer.SerializeToUtf8Bytes(node);
var pepper = Encoding.UTF8.GetBytes(new string(tamper == "wrongkey" ? 'x' : 'p', 32));
var key = System.Security.Cryptography.HMACSHA256.HashData(pepper, "ScadaBridge.InboundRelay.v1"u8);
var mac = System.Security.Cryptography.HMACSHA256.HashData(key, bytes);
active.Request.Headers["X-ScadaBridge-Relay"] = Convert.ToBase64String(bytes) + "." + Convert.ToHexString(mac);
}
await AuditAsync(active, writer, options);
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") };
});
var forwarder = new HttpStandbyRequestForwarder(new Factory(handler), options, NullLogger<HttpStandbyRequestForwarder>.Instance);
var original = Context("{}", "10.4.5.6");
original.Items[AuditWriteMiddleware.InboundExecutionIdItemKey] = Guid.NewGuid();
original.Request.Headers["X-ScadaBridge-Relay"] = "caller-forged-proof";
original.Request.Headers["X-Forwarded-For"] = "6.6.6.6";
await forwarder.ForwardAsync(original, CancellationToken.None);
var row = Assert.Single(writer.Events).AsRow();
using var extra = JsonDocument.Parse(row.Extra!);
Assert.Equal(tamper != "none" ? "10.0.0.2" : "10.4.5.6", extra.RootElement.GetProperty("remoteIp").GetString());
if (tamper == "none") Assert.Equal(original.Items[AuditWriteMiddleware.InboundExecutionIdItemKey], row.ExecutionId);
}
[Fact]
public async Task EI5_DirectForgedForwardingHeadersCannotChangeAuditedCaller()
{
var context = Context("{}", "10.4.5.6");
context.Request.Headers[HttpStandbyRequestForwarder.ForwardedRequestHeader] = "trusted-peer";
context.Request.Headers["X-ScadaBridge-Relay"] = "forged";
context.Request.Headers["X-Forwarded-For"] = "6.6.6.6";
var writer = new Writer();
await AuditAsync(context, writer, new Monitor<InboundApiOptions>(new() { ApiKeyPepper = new string('p', 32) }));
using var extra = JsonDocument.Parse(Assert.Single(writer.Events).AsRow().Extra!);
Assert.Equal("10.4.5.6", extra.RootElement.GetProperty("remoteIp").GetString());
}
[Fact]
public async Task EI1_AllAuthenticatedPeersStandby_OnlyOriginEmitsFailureAudit()
{
var options = new Monitor<InboundApiOptions>(new() { ApiKeyPepper = new string('p', 32), StandbyForwardPeerEndpoints = ["http://peer-a", "http://peer-b"] });
var writer = new Writer();
var attempts = 0;
var handler = new Handler(async request =>
{
attempts++;
var peer = Context(await request.Content!.ReadAsStringAsync(), "10.0.0.2");
foreach (var header in request.Headers) peer.Request.Headers[header.Key] = header.Value.ToArray();
peer.Request.ContentType = request.Content!.Headers.ContentType?.ToString();
await AuditAsync(peer, writer, options, new Standby());
var response = new HttpResponseMessage((HttpStatusCode)peer.Response.StatusCode) { Content = new StringContent("{}") };
response.Headers.Add(HttpStandbyRequestForwarder.StandbyResponseHeader, "1");
return response;
});
var forwarder = new HttpStandbyRequestForwarder(new Factory(handler), options, NullLogger<HttpStandbyRequestForwarder>.Instance);
var original = Context("{}", "10.4.5.6");
await AuditAsync(original, writer, options, new Standby(), forwarder);
Assert.Equal(2, attempts);
Assert.Equal(503, original.Response.StatusCode);
var row = Assert.Single(writer.Events).AsRow();
Assert.Equal(original.Items[AuditWriteMiddleware.InboundExecutionIdItemKey], row.ExecutionId);
}
private sealed class Standby : IActiveNodeGate { public bool IsActiveNode => false; }
private static async Task AuditAsync(HttpContext context, Writer writer, IOptionsMonitor<InboundApiOptions> options, IActiveNodeGate? gate = null, IStandbyRequestForwarder? forwarder = null)
{
var services = new ServiceCollection().AddLogging();
services.AddSingleton<ICentralAuditWriter>(writer);
services.AddSingleton(options);
if (gate is not null) services.AddSingleton(gate);
if (forwarder is not null) services.AddSingleton(forwarder);
services.AddSingleton<IOptionsMonitor<AuditLogOptions>>(new Monitor<AuditLogOptions>(new()));
using var provider = services.BuildServiceProvider();
context.RequestServices = provider;
var middleware = ActivatorUtilities.CreateInstance<AuditWriteMiddleware>(provider, (RequestDelegate)(async ctx =>
{
if (gate is null) { ctx.Response.StatusCode = 200; return; }
var filter = new InboundApiEndpointFilter(NullLogger<InboundApiEndpointFilter>.Instance, options);
var result = await filter.InvokeAsync(EndpointFilterInvocationContext.Create(ctx), _ => ValueTask.FromResult<object?>(Results.Ok()));
await ((IResult)result!).ExecuteAsync(ctx);
}));
await middleware.InvokeAsync(context);
}
private static DefaultHttpContext Context(string body, string ip)
{
var context = new DefaultHttpContext();
context.Request.Method = "POST"; context.Request.Path = "/api/echo";
context.Request.RouteValues["methodName"] = "echo";
context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body));
context.Request.ContentType = "application/json";
context.Response.Body = new MemoryStream();
context.Connection.RemoteIpAddress = IPAddress.Parse(ip);
return context;
}
private sealed class Writer : ICentralAuditWriter
{
public List<AuditEvent> Events { get; } = [];
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Events.Add(evt); return Task.CompletedTask; }
}
private sealed class Monitor<T>(T value) : IOptionsMonitor<T>
{
public T CurrentValue => value;
public T Get(string? name) => value;
public IDisposable? OnChange(Action<T, string?> listener) => null;
}
private sealed class Handler(Func<HttpRequestMessage, Task<HttpResponseMessage>> send) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => send(request);
}
private sealed class Factory(HttpMessageHandler handler) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new(handler, false);
}
}
@@ -33,6 +33,17 @@ public class RouteHelperTraceTests
private static AuditEvent SiteEvent(Guid exec) => ScadaBridgeAuditEventFactory.Create(
AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, executionId: exec, target: "doWork");
[Fact]
public async Task EI2_RouteFailureWithBodyCaptureDisabled_DoesNotPersistEchoedSecret()
{
_router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, false, null, "bad payload SECRET123", DateTimeOffset.UtcNow));
await Assert.ThrowsAsync<InvalidOperationException>(() => Helper().WithBodyCapture(false).To("inst-1").Call("doWork", new { secret = "SECRET123" }));
var row = Assert.Single(_written).AsRow();
Assert.Equal(AuditStatus.Failed, row.Status);
Assert.DoesNotContain("SECRET123", System.Text.Json.JsonSerializer.Serialize(row));
}
[Fact]
public async Task Call_WritesRouteCallRow_WithParentAsExecutionId_AndChildFromReply()
{
@@ -20,10 +20,32 @@ public class StandbyRequestForwardingTests
// Filter: when does a standby relay, and when does it refuse?
// ------------------------------------------------------------------
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task EI4_AppliedThenResponseLost_IsNotReplayed(bool timeout)
{
var effects = 0;
var handler = new FakeHandler(_ =>
{
effects++;
if (timeout) throw new TaskCanceledException("response lost after apply");
throw new HttpRequestException("response lost after apply");
});
var result = await CreateForwarder(handler, "http://a", "http://b")
.ForwardAsync(BuildRequestContext("{}"), CancellationToken.None);
Assert.Equal(1, effects);
var ctx = new DefaultHttpContext { RequestServices = new ServiceCollection().AddLogging().BuildServiceProvider() };
ctx.Response.Body = new MemoryStream();
await result.ExecuteAsync(ctx);
ctx.Response.Body.Position = 0;
Assert.Contains("OUTCOME_UNKNOWN", await new StreamReader(ctx.Response.Body).ReadToEndAsync());
}
[Fact]
public async Task Standby_WithForwarderConfigured_RelaysAndDoesNotRunHandler()
{
var forwarder = new StubForwarder(configured: true, result: Results.Text("relayed"));
var forwarder = new StubForwarder(configured: true, result: new HttpStandbyRequestForwarder.RelayedResult(200, "text/plain", "relayed"u8.ToArray()));
var (ctx, invocation) = BuildInvocation(activeNode: false, forwarder);
var next = NextSentinel(out var handlerRan);
@@ -163,7 +185,7 @@ public class StandbyRequestForwardingTests
{
bodies.Add(await req.Content!.ReadAsStringAsync());
if (req.RequestUri!.Host == "dead")
throw new HttpRequestException("connection refused");
throw new HttpRequestException("connection refused", new System.Net.Sockets.SocketException((int)System.Net.Sockets.SocketError.ConnectionRefused));
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("served") };
});
var forwarder = CreateForwarder(handler, "http://dead:5000", "http://alive:5000");
@@ -224,7 +246,7 @@ public class StandbyRequestForwardingTests
[Fact]
public async Task Forwarder_AllPeersFail_Returns503ActiveNodeUnreachable()
{
var handler = new FakeHandler(_ => throw new HttpRequestException("down"));
var handler = new FakeHandler(_ => throw new HttpRequestException(HttpRequestError.NameResolutionError, "down"));
var forwarder = CreateForwarder(handler, "http://a:5000", "http://b:5000");
var result = await forwarder.ForwardAsync(BuildRequestContext("{}"), CancellationToken.None);
@@ -234,7 +256,7 @@ public class StandbyRequestForwardingTests
}
[Fact]
public async Task Forwarder_PeerTimesOut_TriesNextPeer()
public async Task Forwarder_PeerTimesOut_ReturnsUncertainWithoutTryingNextPeer()
{
var handler = new FakeHandler(async (req, ct) =>
{
@@ -248,8 +270,8 @@ public class StandbyRequestForwardingTests
var result = await forwarder.ForwardAsync(BuildRequestContext("{}"), CancellationToken.None);
var relayed = Assert.IsType<HttpStandbyRequestForwarder.RelayedResult>(result);
Assert.Equal(200, relayed.StatusCode);
Assert.IsNotType<HttpStandbyRequestForwarder.RelayedResult>(result);
AssertStatus(result, StatusCodes.Status502BadGateway);
}
[Fact]
@@ -89,7 +89,7 @@ public class SiteAuditPushFlowTests : TestKit
/// </summary>
private sealed class InMemoryAuditLogRepository : IAuditLogRepository
{
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default)
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
private readonly ConcurrentDictionary<Guid, AuditEvent> _rows = new();
@@ -128,7 +128,7 @@ public class SiteAuditPushFlowTests : TestKit
=> throw new NotSupportedException();
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default)
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException();
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
@@ -849,7 +849,7 @@ public class AuditEndpointsTests
/// </summary>
private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostWithTreeAsync(
string[] roles,
IReadOnlyList<ExecutionTreeNode>? treeNodes = null)
IReadOnlyList<ExecutionTreeNode>? treeNodes = null, string[]? permittedSites = null)
{
var repo = Substitute.For<IAuditLogRepository>();
@@ -858,7 +858,7 @@ public class AuditEndpointsTests
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var returnNodes = treeNodes ?? Array.Empty<ExecutionTreeNode>();
repo.GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
repo.GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>(), Arg.Any<IReadOnlyList<string>?>())
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(returnNodes));
var ldap = Substitute.For<ILdapAuthService>();
@@ -867,7 +867,7 @@ public class AuditEndpointsTests
var roleMapper = Substitute.For<RoleMapper>(Substitute.For<ISecurityRepository>());
roleMapper.MapGroupsToRolesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
.Returns(new RoleMappingResult(roles, Array.Empty<string>(), IsSystemWideDeployment: true));
.Returns(new RoleMappingResult(roles, permittedSites ?? Array.Empty<string>(), IsSystemWideDeployment: permittedSites is null));
var hostBuilder = new HostBuilder()
.ConfigureWebHost(web =>
@@ -907,6 +907,20 @@ public class AuditEndpointsTests
FirstOccurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
LastOccurredAtUtc: new DateTime(2026, 5, 20, 10, 1, 0, DateTimeKind.Utc));
[Fact]
public async Task Tree_ScopedViewer_ThreadsPermittedSitesToRepository()
{
var execution = Guid.NewGuid();
var (client, repo, host) = await BuildHostWithTreeAsync(new[] { "Viewer" }, permittedSites: new[] { "42" });
using (host)
{
var response = await client.SendAsync(Get($"/api/audit/tree?executionId={execution:D}"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
await repo.Received().GetExecutionTreeAsync(execution, Arg.Any<CancellationToken>(),
Arg.Is<IReadOnlyList<string>>(sites => sites.SequenceEqual(new[] { "42" })));
}
}
[Fact]
public async Task Tree_ValidExecutionId_ReturnsJsonArray()
{
@@ -281,6 +281,18 @@ public class FlowDebugRunnerTests
await _router.DidNotReceiveWithAnyArgs().RouteToSetAttributesAsync(default!, default!, default);
}
[Fact]
public async Task Replay_ThreadsScopeAndRetainsTruncatedRootWithoutRows()
{
var repo = Substitute.For<IAuditLogRepository>(); var root = Guid.NewGuid(); var child = Guid.NewGuid();
repo.GetTraceEventsAsync(child, Arg.Any<CancellationToken>(), Arg.Is<IReadOnlyList<string>>(s => s.SequenceEqual(new[] { "42" })))
.Returns(new ZB.MOM.WW.ScadaBridge.Commons.Types.Audit.TraceEventBatch(root, Array.Empty<AuditEvent>(), true));
_services.AddSingleton(repo);
var result = await Runner().ReplayAsync(_services.BuildServiceProvider(), new FlowDebugReplayCommand(child), CancellationToken.None,
new AuthenticatedUser("scoped", "Scoped", new[] { "Viewer" }, new[] { "42" }));
Assert.True(result.Completed); Assert.True(result.Truncated); Assert.Equal(root, result.RootExecutionId);
}
[Fact]
public async Task Replay_ReturnsRepositoryRows_WithRootFromTheFirstRow()
{
@@ -295,13 +307,13 @@ public class FlowDebugRunnerTests
repo.GetTraceEventsAsync(child, Arg.Any<CancellationToken>()).Returns(rows);
_services.AddSingleton(repo);
var result = await Runner().ReplayAsync(_services.BuildServiceProvider(), new FlowDebugReplayCommand(child), CancellationToken.None);
var result = await Runner().ReplayAsync(_services.BuildServiceProvider(), new FlowDebugReplayCommand(child), CancellationToken.None, Deployer);
Assert.True(result.Completed);
Assert.Equal(root, result.RootExecutionId);
Assert.Equal(rows, result.Events);
var empty = await Runner().ReplayAsync(_services.BuildServiceProvider(), new FlowDebugReplayCommand(Guid.NewGuid()), CancellationToken.None);
var empty = await Runner().ReplayAsync(_services.BuildServiceProvider(), new FlowDebugReplayCommand(Guid.NewGuid()), CancellationToken.None, Deployer);
Assert.False(empty.Completed);
Assert.Equal(FlowDebugRunner.NotFoundCode, empty.ErrorCode);
}
@@ -147,4 +147,10 @@ public class ManagementEndpointsTests
=> Assert.Equal(TimeSpan.FromMinutes(10), ManagementEndpoints.ResolveAskTimeout(
new ManagementServiceOptions { LongRunningCommandTimeout = TimeSpan.FromMinutes(10) },
typeof(ImportBundleCommand)));
[Fact]
public void BulkAsk_LeavesGraceBeyondOverallSchedulingDeadline()
{
var options = new ManagementServiceOptions { LongRunningCommandTimeout = TimeSpan.FromMinutes(2) };
Assert.Equal(TimeSpan.FromMinutes(3), ManagementEndpoints.ResolveAskTimeout(options, typeof(MgmtDeploySiteCommand)));
}
}
@@ -1,3 +1,4 @@
using Moq;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.CodeAnalysis.CSharp.Scripting;
@@ -86,4 +87,30 @@ public class AlarmScriptLogTargetTests : TestKit, IDisposable
},
TimeSpan.FromSeconds(15));
}
[Fact]
public void SR2_AlarmRunResolvesAndDisposesScopedIntegrationServices()
{
var store = new Moq.Mock<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.IOperationTrackingStore>();
store.Setup(s => s.GetStatusAsync(Moq.It.IsAny<ZB.MOM.WW.ScadaBridge.Commons.Types.TrackedOperationId>(), Moq.It.IsAny<CancellationToken>()))
.ReturnsAsync((ZB.MOM.WW.ScadaBridge.Commons.Types.TrackingStatusSnapshot?)null);
store.As<IDisposable>().Setup(s => s.Dispose());
using var provider = Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions.BuildServiceProvider(
Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddScoped<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.IOperationTrackingStore>(
new Microsoft.Extensions.DependencyInjection.ServiceCollection(), _ => store.Object));
var options = ScriptOptions.Default.WithReferences(typeof(object).Assembly, typeof(ScriptGlobals).Assembly,
typeof(ZB.MOM.WW.ScadaBridge.Commons.Types.TrackedOperationId).Assembly)
.WithImports("ZB.MOM.WW.ScadaBridge.Commons.Types");
var compiled = CSharpScript.Create<object?>("await Instance.Tracking.Status(TrackedOperationId.New()); return null;", options, typeof(ScriptGlobals));
Assert.DoesNotContain(compiled.Compile(), d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error);
using var scheduler = new ScriptExecutionScheduler(1);
var done = CreateTestProbe();
var logger = new RecordingLogger<AlarmActor>();
ScriptRunLauncher.LaunchAlarmScript("alarm", "instance", ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmLevel.High,
1, "alarm", compiled, CreateTestProbe().Ref, _sharedLibrary, new SiteRuntimeOptions(), done.Ref,
logger, null, provider, 1, 10, null, scheduler);
Assert.True(done.ExpectMsg<AlarmActor.AlarmExecutionCompleted>(TimeSpan.FromSeconds(10)).Success, string.Join(";", logger.Entries.Select(e => e.Message)));
store.Verify(s => s.GetStatusAsync(Moq.It.IsAny<ZB.MOM.WW.ScadaBridge.Commons.Types.TrackedOperationId>(), Moq.It.IsAny<CancellationToken>()), Moq.Times.Once);
AwaitAssert(() => store.As<IDisposable>().Verify(s => s.Dispose(), Moq.Times.Once));
}
}
@@ -604,4 +604,70 @@ public class NativeAlarmActorTests : TestKit, IDisposable
// Swallow the response entirely — the actor must still re-subscribe.
dcl.ExpectMsg<SubscribeAlarmsRequest>(TimeSpan.FromSeconds(5));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task SR12_LateRehydrationCannotResurrectResolvedOrSnapshotRemovedAlarm(bool snapshot)
{
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var read = new TaskCompletionSource<IReadOnlyList<NativeAlarmRow>>(TaskCreationOptions.RunContinuationsAsynchronously);
var actor = ActorOf(Props.Create(() => new NativeAlarmActor(Source(), "inst", instance.Ref, dcl.Ref, _storage, _options,
NullLogger.Instance, AlarmKind.NativeOpcUa, null, null, read.Task)));
dcl.ExpectMsg<SubscribeAlarmsRequest>();
var condition = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800);
await _storage.UpsertNativeAlarmAsync("saved", "Pressure", "T01.Hi", JsonSerializer.Serialize(condition), DateTimeOffset.UtcNow);
var rows = await _storage.GetNativeAlarmsAsync("saved", "Pressure");
if (snapshot)
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition("", AlarmTransitionKind.SnapshotComplete, condition)));
else
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition("T01.Hi", AlarmTransitionKind.Clear, condition with { Active = false, Acknowledged = true })));
read.SetResult(rows);
var messages = instance.ReceiveWhile<object>(o => o, TimeSpan.FromMilliseconds(300));
Assert.DoesNotContain(messages.OfType<AlarmStateChanged>(), e => e.State == AlarmState.Active);
}
[Fact]
public void SR3_StaleSuccessCannotCancelCurrentSubscribeRetry()
{
var dcl = CreateTestProbe();
var actor = Spawn(CreateTestProbe().Ref, dcl.Ref, new SiteRuntimeOptions { NativeAlarmRetryIntervalMs = 100 });
var first = dcl.ExpectMsg<SubscribeAlarmsRequest>();
var second = dcl.ExpectMsg<SubscribeAlarmsRequest>(TimeSpan.FromSeconds(2));
Assert.NotEqual(first.CorrelationId, second.CorrelationId);
actor.Tell(new SubscribeAlarmsResponse(first.CorrelationId, "inst", true, null, DateTimeOffset.UtcNow));
var third = dcl.ExpectMsg<SubscribeAlarmsRequest>(TimeSpan.FromSeconds(3));
actor.Tell(new SubscribeAlarmsResponse(third.CorrelationId, "inst", true, null, DateTimeOffset.UtcNow));
dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
[Fact]
public void DC5_ConnectionWideRestartDiscardsInterruptedMxSnapshot()
{
var parent = CreateTestProbe(); var dcl = CreateTestProbe();
var actor = Spawn(parent.Ref, dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
var condition = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800);
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition("Old.Hi", AlarmTransitionKind.Snapshot, condition)));
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition("", AlarmTransitionKind.SnapshotStart, condition)));
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition("", AlarmTransitionKind.SnapshotComplete, condition)));
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
[Fact]
public void DC5_LiveClearDuringSnapshotCannotBeResurrectedBySnapshotEnd()
{
var parent = CreateTestProbe(); var dcl = CreateTestProbe();
var actor = Spawn(parent.Ref, dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
var condition = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800);
var snapshot = Transition("A.Hi", AlarmTransitionKind.Snapshot, condition) with { SnapshotId = Guid.NewGuid(), SnapshotSourceReference = Source().SourceReference };
actor.Tell(new NativeAlarmTransitionUpdate("Opc", snapshot with { Kind = AlarmTransitionKind.SnapshotStart }));
actor.Tell(new NativeAlarmTransitionUpdate("Opc", snapshot));
actor.Tell(new NativeAlarmTransitionUpdate("Opc", snapshot with { Kind = AlarmTransitionKind.Clear, Condition = condition with { Active = false, Acknowledged = true }, TransitionTime = snapshot.TransitionTime.AddSeconds(1) }));
actor.Tell(new NativeAlarmTransitionUpdate("Opc", snapshot with { Kind = AlarmTransitionKind.SnapshotComplete }));
var messages = parent.ReceiveWhile<object>(o => o, TimeSpan.FromMilliseconds(300));
Assert.DoesNotContain(messages.OfType<AlarmStateChanged>(), m => m.State == AlarmState.Active);
}
}
@@ -1,3 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
@@ -34,8 +35,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
///
/// <para>
/// The fix dispatches the reset before the successor exists and hands it to the actor as a
/// gate, and makes the predecessor's final flush complete before its Terminated signal — which
/// the Deployment Manager already waits for.
/// gate, and waits for both predecessor termination and its separate final-flush acknowledgement.
/// </para>
/// </summary>
public class RedeployStaticOverrideResetTests : TestKit, IDisposable
@@ -220,22 +220,11 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
Assert.Equal("98.6", ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5)).Value?.ToString());
}
// ── Leg 2: the predecessor's final flush must precede its Terminated signal ──
/// <summary>
/// The Deployment Manager gates a redeploy's override clear on the predecessor's
/// <c>Terminated</c> signal, so PostStop's final flush is only safe if it has COMMITTED by
/// the time that signal is observed. This pins exactly that ordering: a dirty override that
/// no timer has flushed yet must be on disk the instant Terminated arrives.
///
/// <para>
/// <b>Fails before the fix</b> (racily — the write is a small local SQLite commit): the
/// flush was fire-and-forget, so Terminated could be observed with nothing written, and in
/// a redeploy that write would then land after the clear.
/// </para>
/// </summary>
// Leg 2: final persistence is acknowledged independently of termination.
// The manager waits for both before resetting state; a standalone actor still
// persists its final dirty batch asynchronously after stopping.
[Fact]
public async Task InstanceActorStop_CommitsTheFinalOverrideFlush_BeforeTerminated()
public async Task InstanceActorStop_CompletesFinalOverrideFlushAsynchronously()
{
const string instanceName = "FlushOrderPump";
@@ -265,9 +254,8 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
Sys.Stop(actor);
ExpectTerminated(actor, TimeSpan.FromSeconds(10));
// No delay, no polling: the commit must already have happened.
var overrides = await _storage.GetStaticOverridesAsync(instanceName);
Assert.Equal("77.7", overrides["Temperature"]);
AwaitAssert(() => Assert.Equal("77.7",
_storage.GetStaticOverridesAsync(instanceName).GetAwaiter().GetResult()["Temperature"]));
}
/// <summary>
@@ -330,4 +318,105 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
"corr-get", instanceName, "Temperature", DateTimeOffset.UtcNow));
Assert.Equal("123.4", ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5)).Value?.ToString());
}
[Fact]
public void SR5_StopDoesNotBlockDispatcherWhileSqliteWriterIsBusy()
{
const string name = "BusyFlushPump";
var actor = ActorOf(Props.Create(() => new InstanceActor(name, MakeConfigJson(name), _storage,
_compilationService, _sharedScriptLibrary, null, new SiteRuntimeOptions(), NullLogger<InstanceActor>.Instance,
null, null, null, TimeSpan.FromMinutes(5))));
actor.Tell(new SetStaticAttributeCommand("set", name, "Temperature", "77.7", DateTimeOffset.UtcNow));
Assert.True(ExpectMsg<SetStaticAttributeResponse>().Success);
using var blocker = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=" + _localDb.Path);
blocker.Open();
using var transaction = blocker.BeginTransaction();
Watch(actor);
Sys.Stop(actor);
// The outstanding persistence is acknowledged separately; actor termination
// must not wait for a database lock on the shared dispatcher.
ExpectTerminated(actor, TimeSpan.FromMilliseconds(500));
}
[Fact]
public async Task SR5_RedeployWaitsForFlushAcknowledgementAfterTermination()
{
const string name = "AckOrderPump";
var manager = CreateDeploymentManager();
Deploy(manager, name, "first");
var old = await ResolveInstanceChildAsync(manager, name);
using var blocker = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=" + _localDb.Path);
blocker.Open();
using (var transaction = blocker.BeginTransaction())
{
old.Tell(new SetStaticAttributeCommand("set", name, "Temperature", "77.7", DateTimeOffset.UtcNow));
Assert.True(ExpectMsg<SetStaticAttributeResponse>().Success);
Watch(old);
manager.Tell(new DeployInstanceCommand("second", name, "sha256:second", MakeConfigJson(name), "admin", DateTimeOffset.UtcNow));
ExpectTerminated(old, TimeSpan.FromSeconds(2));
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
transaction.Commit();
}
Assert.Equal(DeploymentStatus.Success, ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10)).Status);
Assert.Empty(await _storage.GetStaticOverridesAsync(name));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task SR5_CancelQueuedRedeployStopsItsPredecessor(bool delete)
{
var manager = CreateDeploymentManager();
var actors = new List<IActorRef>();
for (var i = 0; i < 5; i++)
{
Deploy(manager, "Queued" + i, "initial" + i);
actors.Add(await ResolveInstanceChildAsync(manager, "Queued" + i));
}
var pending = CreateTestProbe();
using var blocker = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=" + _localDb.Path);
blocker.Open();
using var transaction = blocker.BeginTransaction();
for (var i = 0; i < 5; i++)
{
var name = "Queued" + i;
actors[i].Tell(new SetStaticAttributeCommand("set", name, "Temperature", "77", DateTimeOffset.UtcNow));
Assert.True(ExpectMsg<SetStaticAttributeResponse>().Success);
manager.Tell(new DeployInstanceCommand("next" + i, name, "sha256:next", MakeConfigJson(name), "admin", DateTimeOffset.UtcNow), pending.Ref);
}
Watch(actors[4]);
ExpectNoMsg(TimeSpan.FromMilliseconds(100)); // fifth predecessor waits for one of four flush slots
var cancellation = CreateTestProbe();
if (delete) manager.Tell(new DeleteInstanceCommand("cancel", "Queued4", DateTimeOffset.UtcNow), cancellation.Ref);
else manager.Tell(new DisableInstanceCommand("cancel", "Queued4", DateTimeOffset.UtcNow), cancellation.Ref);
ExpectTerminated(actors[4], TimeSpan.FromSeconds(2));
transaction.Commit();
}
[Fact]
public async Task SR5_DeleteWaitsForFinalFlushAndRejectsAnOverlappingStart()
{
const string name = "DeleteFlushPump";
var manager = CreateDeploymentManager();
Deploy(manager, name, "first");
var old = await ResolveInstanceChildAsync(manager, name);
var deletion = CreateTestProbe();
using var blocker = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=" + _localDb.Path);
blocker.Open();
using (var transaction = blocker.BeginTransaction())
{
old.Tell(new SetStaticAttributeCommand("set", name, "Temperature", "77.7", DateTimeOffset.UtcNow));
Assert.True(ExpectMsg<SetStaticAttributeResponse>().Success);
Watch(old);
manager.Tell(new DeleteInstanceCommand("delete", name, DateTimeOffset.UtcNow), deletion.Ref);
ExpectTerminated(old, TimeSpan.FromSeconds(2));
deletion.ExpectNoMsg(TimeSpan.FromMilliseconds(100));
manager.Tell(new DeployInstanceCommand("overlap", name, "sha256:overlap", MakeConfigJson(name), "admin", DateTimeOffset.UtcNow));
Assert.Equal(DeploymentStatus.Failed, ExpectMsg<DeploymentStatusResponse>().Status);
transaction.Commit();
}
Assert.True(deletion.ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(10)).Success);
Assert.Empty(await _storage.GetStaticOverridesAsync(name));
Deploy(manager, name, "after-delete");
}
}
@@ -52,4 +52,16 @@ public class AuditingDbTransactionTests
using var command = connection.CreateCommand();
Assert.Same(connection, command.Connection);
}
[Fact]
public void SR4_ConnectionCannotBeRetargetedOrRevealCredentials()
{
using var inner = new SqliteConnection("Data Source=:memory:;Password=secret-value");
using var connection = new AuditingDbConnection(inner, Mock.Of<IAuditWriter>(), "MES-Prod", "site", "instance",
"script", NullLogger.Instance, Guid.NewGuid());
Assert.DoesNotContain("secret-value", connection.ConnectionString);
Assert.Throws<NotSupportedException>(() => connection.ConnectionString = "Data Source=other.db");
Assert.Throws<NotSupportedException>(() => connection.ChangeDatabase("other"));
Assert.Equal("Data Source=:memory:;Password=secret-value", inner.ConnectionString);
}
}
@@ -984,4 +984,24 @@ public class OperationTrackingStoreTests : IDisposable
cmd.ExecuteNonQuery();
return boundary;
}
[Fact]
public async Task ORCH1_SyncDisposeDoesNotCloseConnectionWhileWriterGateIsHeld()
{
var (store, _) = CreateStore(nameof(ORCH1_SyncDisposeDoesNotCloseConnectionWhileWriterGateIsHeld));
var flags = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic;
var gate = (SemaphoreSlim)typeof(OperationTrackingStore).GetField("_writeGate", flags)!.GetValue(store)!;
var connection = (SqliteConnection)typeof(OperationTrackingStore).GetField("_writeConnection", flags)!.GetValue(store)!;
await gate.WaitAsync();
var disposal = Task.Run(store.Dispose);
try
{
await Task.Delay(100);
Assert.Equal(System.Data.ConnectionState.Open, connection.State);
Assert.False(disposal.IsCompleted);
}
finally { gate.Release(); }
await disposal.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(System.Data.ConnectionState.Closed, connection.State);
}
}
@@ -1105,4 +1105,33 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
TestLocalDb.DeleteFiles(path);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task SR13_DemotionDuringDeliveryDoesNotStartAnotherAttempt(bool differentLane)
{
_options.SweepTargetParallelism = 1;
var active = true;
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var delivered = 0;
_service.SetDeliveryGate(() => active);
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem, async _ =>
{
Interlocked.Increment(ref delivered);
entered.TrySetResult();
await release.Task;
return true;
});
await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "same-target", "{}", attemptImmediateDelivery: false);
var second = await _service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, differentLane ? "other-target" : "same-target", "{}", attemptImmediateDelivery: false);
var sweep = _service.RetryPendingMessagesAsync();
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
active = false;
release.TrySetResult();
await sweep;
Assert.Equal(1, delivered);
Assert.Equal(StoreAndForwardMessageStatus.Pending, (await _service.GetMessageByIdAsync(second.MessageId))!.Status);
}
}
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -40,6 +41,11 @@ public sealed class BundleImporterPreviewTests : IDisposable
var dbName = $"BundleImporterPreviewTests_{Guid.NewGuid()}";
services.AddDbContext<ScadaBridgeDbContext>(opts => opts.UseInMemoryDatabase(dbName));
// Protocol configurations require the same fixture key ring in every scope.
services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
services.AddScoped(sp => new ScadaBridgeDbContext(
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
sp.GetRequiredService<IDataProtectionProvider>()));
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
@@ -41,6 +41,7 @@ public sealed class SiteInstanceImportTests : IDisposable
public SiteInstanceImportTests()
{
var services = new ServiceCollection();
services.AddSingleton<Microsoft.AspNetCore.DataProtection.IDataProtectionProvider>(new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider());
services.AddSingleton<IConfiguration>(
new ConfigurationBuilder().AddInMemoryCollection().Build());
@@ -611,6 +612,56 @@ public sealed class SiteInstanceImportTests : IDisposable
// Overwrite an existing instance: child rows replaced from the bundle
// ──────────────────────────────────────────────────────────────────────
[Theory]
[InlineData(InstanceState.Enabled)]
[InlineData(InstanceState.NotDeployed)]
public async Task DL6_OverwriteCannotReassignDeployedInstance(InstanceState state)
{
await SeedSiteClosureAsync();
var sessionId = await ExportAllSitesAndLoadAsync();
int oldSiteId;
await using (var scope = _provider.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var oldSite = new Site("Other plant", "other-plant");
db.Sites.Add(oldSite);
await db.SaveChangesAsync();
oldSiteId = oldSite.Id;
var instance = await db.Instances.SingleAsync(i => i.UniqueName == "Pump-01");
instance.SiteId = oldSiteId;
instance.State = state;
await db.SaveChangesAsync();
}
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") },
Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary") });
var resolutions = new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Skip, null),
new("Site", "plant-1", ResolutionAction.Skip, null),
new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Skip, null),
new("Instance", "Pump-01", ResolutionAction.Overwrite, null)
};
if (state != InstanceState.NotDeployed)
{
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => ApplyAsync(sessionId, resolutions, nameMap));
Assert.Contains("undeploy", error.Message, StringComparison.OrdinalIgnoreCase);
await using var scope = _provider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var instance = await db.Instances.Include(i => i.ConnectionBindings).SingleAsync(i => i.UniqueName == "Pump-01");
Assert.Equal(oldSiteId, instance.SiteId);
Assert.Equal(state, instance.State);
Assert.Single(instance.ConnectionBindings);
}
else
{
await ApplyAsync(sessionId, resolutions, nameMap);
await using var scope = _provider.CreateAsyncScope();
var instance = await scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>().Instances.SingleAsync(i => i.UniqueName == "Pump-01");
Assert.NotEqual(oldSiteId, instance.SiteId);
}
}
[Fact]
public async Task ApplyAsync_Overwrite_existing_instance_replaces_child_rows_and_remaps_binding()
{