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:
+2
-2
@@ -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) =>
|
||||
|
||||
+2
-2
@@ -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) =>
|
||||
|
||||
+8
-8
@@ -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(); }
|
||||
}
|
||||
}
|
||||
+35
@@ -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());
|
||||
}
|
||||
}
|
||||
+86
@@ -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());
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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"))));
|
||||
}
|
||||
}
|
||||
+1
@@ -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); }
|
||||
}
|
||||
}
|
||||
+9
@@ -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>
|
||||
|
||||
+98
@@ -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);
|
||||
}
|
||||
}
|
||||
+77
@@ -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);
|
||||
}
|
||||
}
|
||||
+42
@@ -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()
|
||||
{
|
||||
|
||||
+98
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+6
-6
@@ -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]
|
||||
|
||||
+42
-2
@@ -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
|
||||
|
||||
+23
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+109
-20
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+6
@@ -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>();
|
||||
|
||||
+51
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user