Resolve all 46 low-severity architecture review findings
This commit is contained in:
+2
-2
@@ -449,8 +449,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||
_inner.SwitchOutPartitionAsync(monthBoundary, commandTimeout, ct);
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||
_inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct);
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||
_inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct, maxRows);
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default) =>
|
||||
_inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
|
||||
|
||||
@@ -411,7 +411,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
@@ -470,8 +470,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
_inner.SwitchOutPartitionAsync(monthBoundary, commandTimeout, ct);
|
||||
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||
_inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct);
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||
_inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct, maxRows);
|
||||
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||
|
||||
@@ -62,7 +62,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
// M5.5 (T3): records every per-channel purge call as
|
||||
// (channel, threshold, batchSize) so tests can assert which channels the
|
||||
// actor chose to purge and with what window.
|
||||
public List<(string Channel, DateTime Threshold, int BatchSize)> ChannelPurges { get; } = new();
|
||||
public List<(string Channel, DateTime Threshold, int BatchSize, int MaxRows)> ChannelPurges { get; } = new();
|
||||
public Func<string, long> RowsPerChannel { get; set; } = _ => 0L;
|
||||
|
||||
// The actor enumerator returns whichever list is configured here.
|
||||
@@ -96,9 +96,9 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
}
|
||||
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default)
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000)
|
||||
{
|
||||
ChannelPurges.Add((channel, threshold, batchSize));
|
||||
ChannelPurges.Add((channel, threshold, batchSize, maxRows));
|
||||
ChannelTimeouts.Add(commandTimeout);
|
||||
return Task.FromResult(RowsPerChannel(channel));
|
||||
}
|
||||
@@ -479,6 +479,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
var repo = new RecordingRepo { Boundaries = new List<DateTime>() };
|
||||
var purgeOptions = FastTickOptions();
|
||||
purgeOptions.ChannelPurgeBatchSizeConfigured = 1234;
|
||||
purgeOptions.MaxPurgedRowsPerChannelPerTick = 4321;
|
||||
|
||||
// Build the options OUTSIDE the Props expression tree — a collection/dictionary
|
||||
// initializer is not legal inside an expression-tree lambda (CS8074).
|
||||
@@ -505,6 +506,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
|
||||
var purge = repo.ChannelPurges.First(p => p.Channel == "ApiOutbound");
|
||||
Assert.Equal(1234, purge.BatchSize);
|
||||
Assert.Equal(4321, purge.MaxRows);
|
||||
|
||||
var expected = DateTime.UtcNow - TimeSpan.FromDays(30);
|
||||
Assert.True(
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ public class CentralAuditWriteFailuresTests : TestKit
|
||||
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||
Task.FromResult(0L);
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||
Task.FromResult(0L);
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||
|
||||
+4
-4
@@ -93,7 +93,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
Task.FromResult(0L);
|
||||
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||
Task.FromResult(0L);
|
||||
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
@@ -159,7 +159,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
Task.FromResult(0L);
|
||||
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||
Task.FromResult(0L);
|
||||
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
@@ -225,7 +225,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
Task.FromResult(0L);
|
||||
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||
Task.FromResult(0L);
|
||||
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
@@ -628,7 +628,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null,
|
||||
CancellationToken ct = default)
|
||||
CancellationToken ct = default, int maxRows = 50_000)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
|
||||
+9
@@ -12,6 +12,15 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
/// </summary>
|
||||
public class SiteAuditReconciliationOptionsValidatorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1, true)]
|
||||
[InlineData(2000, true)]
|
||||
[InlineData(2001, false)]
|
||||
[InlineData(int.MaxValue, false)]
|
||||
public void BatchSize_MatchesRpcCeiling(int size, bool valid)
|
||||
=> Assert.Equal(valid, new SiteAuditReconciliationOptionsValidator().Validate(null,
|
||||
new SiteAuditReconciliationOptions { BatchSize = size }).Succeeded);
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_AreValid()
|
||||
{
|
||||
|
||||
@@ -128,7 +128,7 @@ public class SiteAuditRetentionServiceTests
|
||||
private sealed class RecordingSiteAuditQueue : ISiteAuditQueue
|
||||
{
|
||||
public Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync(
|
||||
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default)
|
||||
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null)
|
||||
=> throw new NotSupportedException("not exercised by the retention service");
|
||||
|
||||
private readonly ConcurrentQueue<DateTime> _purgeCalls = new();
|
||||
|
||||
+68
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||
@@ -32,6 +33,73 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, kind, AuditStatus.Delivered,
|
||||
occurredAtUtc: at, executionId: exec, parentExecutionId: parent, sourceSiteId: "site-a");
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(64)]
|
||||
public async Task TraceReadPlan_UsesExecutionIndexesInsteadOfRetainedStoreScan(int idCount)
|
||||
{
|
||||
await using var writer = CreateWriter();
|
||||
using var connection = new SqliteConnection($"Data Source={_dbPath}");
|
||||
await connection.OpenAsync();
|
||||
using var command = connection.CreateCommand();
|
||||
var names = Enumerable.Range(0, idCount).Select(i => $"$e{i}").ToArray();
|
||||
command.CommandText = "EXPLAIN QUERY PLAN " + SqliteAuditWriter.BuildTraceReadSql(string.Join(", ", names));
|
||||
foreach (var name in names) command.Parameters.AddWithValue(name, Guid.NewGuid().ToString("D"));
|
||||
command.Parameters.AddWithValue("$since", DateTime.MinValue.ToString("o"));
|
||||
command.Parameters.AddWithValue("$after", DBNull.Value);
|
||||
command.Parameters.AddWithValue("$limit", 2000);
|
||||
var plan = new List<string>();
|
||||
using var reader = await command.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync()) plan.Add(reader.GetString(3));
|
||||
Assert.Contains(plan, p => p.Contains("SEARCH audit_event USING INDEX IX_audit_event_execution", StringComparison.Ordinal));
|
||||
Assert.Contains(plan, p => p.Contains("SEARCH audit_event USING INDEX IX_audit_event_parent_execution", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(plan, p => p.StartsWith("SCAN audit_event", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyMalformedDetails_DoNotBreakStartupOrTraceReads()
|
||||
{
|
||||
var root = Guid.NewGuid();
|
||||
var row = Row(T0, root, null);
|
||||
await using (var writer = CreateWriter()) await writer.WriteAsync(row);
|
||||
using (var connection = new SqliteConnection($"Data Source={_dbPath}"))
|
||||
{
|
||||
await connection.OpenAsync();
|
||||
using var command = connection.CreateCommand();
|
||||
// Simulate a pre-upgrade file, including malformed payloads from an older writer.
|
||||
command.CommandText = "DROP INDEX IF EXISTS IX_audit_event_execution; DROP INDEX IF EXISTS IX_audit_event_parent_execution; "
|
||||
+ "INSERT INTO audit_event(EventId,OccurredAtUtc,Actor,Action,Outcome,DetailsJson) VALUES ($id,$at,'legacy','legacy','Success','{broken');";
|
||||
command.Parameters.AddWithValue("$id", Guid.NewGuid().ToString("D"));
|
||||
command.Parameters.AddWithValue("$at", T0.ToString("o"));
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
await using var reopened = CreateWriter();
|
||||
var result = await reopened.ReadByExecutionIdsAsync([root], DateTime.MinValue, 2000);
|
||||
Assert.Equal(row.EventId, Assert.Single(result).EventId);
|
||||
// Index initialization remains idempotent on the upgraded database.
|
||||
await using var reopenedAgain = CreateWriter();
|
||||
Assert.Equal(row.EventId, Assert.Single(await reopenedAgain.ReadByExecutionIdsAsync([root], DateTime.MinValue, 2000)).EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cursor_PagesEqualTimestampsWithoutRepeatingOrSkippingRows()
|
||||
{
|
||||
var root = Guid.NewGuid();
|
||||
await using var writer = CreateWriter();
|
||||
var rows = Enumerable.Range(0, 7).Select(_ => Row(T0, root, null)).ToArray();
|
||||
foreach (var row in rows) await writer.WriteAsync(row);
|
||||
var first = await writer.ReadByExecutionIdsAsync([root], T0, 3);
|
||||
// Reflection lets the pre-fix implementation demonstrate its repeated oldest page.
|
||||
var method = typeof(SqliteAuditWriter).GetMethod(nameof(SqliteAuditWriter.ReadByExecutionIdsAsync))!;
|
||||
object[] args = method.GetParameters().Length == 5
|
||||
? [new[] { root }, T0, 3, CancellationToken.None, first[^1].EventId.ToString("D")]
|
||||
: [new[] { root }, T0, 3, CancellationToken.None];
|
||||
var second = await (Task<IReadOnlyList<AuditEvent>>)method.Invoke(writer, args)!;
|
||||
Assert.Equal(6, first.Concat(second).Select(e => e.EventId).Distinct().Count());
|
||||
Assert.Equal(rows.OrderBy(e => e.EventId.ToString("D"), StringComparer.Ordinal).Take(6).Select(e => e.EventId),
|
||||
first.Concat(second).Select(e => e.EventId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchesExecutionId_OrParentExecutionId_OrderedAndBounded()
|
||||
{
|
||||
|
||||
+2
-7
@@ -11,13 +11,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka.TestKit.Xunit2" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<!--
|
||||
Bundle D D2 needs Microsoft.Data.SqlClient for the MsSqlMigrationFixture
|
||||
(mirroring ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests). Pinning 6.1.1 here for
|
||||
the same reason: EF SqlServer 10.0.7 needs >= 6.1.1 but the central pin
|
||||
is 6.0.2 (production ExternalSystemGateway). Override is test-only.
|
||||
-->
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" VersionOverride="6.1.1" />
|
||||
<!-- Use the central SqlClient pin, matching the production EF dependency floor. -->
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||
<!-- Security pin: GHSA-2m69-gcr7-jv3q. Microsoft.Data.Sqlite pulls the
|
||||
vulnerable native 2.1.11; 2.1.12 patches it. Bumping Sqlite does not help. -->
|
||||
|
||||
@@ -28,7 +28,7 @@ public class CommandTreeTests
|
||||
InstanceCommands.Build(Url, Format, Username, Password),
|
||||
SiteCommands.Build(Url, Format, Username, Password),
|
||||
DeployCommands.Build(Url, Format, Username, Password),
|
||||
DataConnectionCommands.Build(Url, Format, Username, Password),
|
||||
BuildDataConnectionGroup(),
|
||||
ExternalSystemCommands.Build(Url, Format, Username, Password),
|
||||
NotificationCommands.Build(Url, Format, Username, Password),
|
||||
SecurityCommands.Build(Url, Format, Username, Password),
|
||||
@@ -41,9 +41,30 @@ public class CommandTreeTests
|
||||
ApiMethodCommands.Build(Url, Format, Username, Password),
|
||||
BundleCommands.Build(Url, Format, Username, Password),
|
||||
CachedCallCommands.Build(Url, Format, Username, Password),
|
||||
SecuredWriteCommands.Build(Url, Format, Username, Password),
|
||||
FlowDebugCommands.Build(Url, Format, Username, Password),
|
||||
};
|
||||
|
||||
private static Command BuildDataConnectionGroup()
|
||||
{
|
||||
var command = DataConnectionCommands.Build(Url, Format, Username, Password);
|
||||
ConnectionBrowseCommands.AddTo(command, Url, Format, Username, Password);
|
||||
return command;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisteredOperationalGroups_IncludeSecuredWritesAndConnectionTools()
|
||||
{
|
||||
var groups = AllCommandGroups().ToList();
|
||||
var secured = groups.Single(g => g.Name == "secured-write");
|
||||
Assert.Equal("list", Assert.Single(secured.Subcommands).Name);
|
||||
var connection = groups.Single(g => g.Name == "data-connection");
|
||||
foreach (var name in new[] { "browse", "search", "verify-endpoint", "certs" })
|
||||
Assert.Contains(connection.Subcommands, c => c.Name == name);
|
||||
var certs = connection.Subcommands.Single(c => c.Name == "certs");
|
||||
Assert.Equal(new[] { "list", "remove", "trust" }, certs.Subcommands.Select(c => c.Name).Order());
|
||||
}
|
||||
|
||||
private static IEnumerable<Command> LeafCommands(Command command)
|
||||
{
|
||||
if (command.Subcommands.Count == 0)
|
||||
@@ -62,11 +83,11 @@ public class CommandTreeTests
|
||||
{
|
||||
var groups = AllCommandGroups().ToList();
|
||||
// CLI-022: bump this count whenever a new top-level command group is
|
||||
// registered in Program.cs. Current registered groups (18):
|
||||
// registered in Program.cs. Current registered groups (19):
|
||||
// template, instance, site, deploy, data-connection, external-system,
|
||||
// notification, security, audit-config, audit, health, debug,
|
||||
// shared-script, db-connection, api-method, bundle, cached-call, flow-debug.
|
||||
Assert.Equal(18, groups.Count);
|
||||
// shared-script, db-connection, api-method, bundle, cached-call, secured-write, flow-debug.
|
||||
Assert.Equal(19, groups.Count);
|
||||
Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name)));
|
||||
}
|
||||
|
||||
|
||||
+13
-2
@@ -304,7 +304,18 @@ public class TopologyAreaTests : IClassFixture<DeploymentFixture>
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
var live = page.Locator("#live-updates");
|
||||
if (await live.IsCheckedAsync()) await live.UncheckAsync(); // stop the 15s tree reload
|
||||
await page.Locator("button[aria-label='Expand all areas']").ClickAsync();
|
||||
return page;
|
||||
// Lazy topology loads a bounded first page per branch. These small authoring
|
||||
// fixtures need their sites/areas loaded before addressing labels or instances.
|
||||
for (var branch = 0; branch < 300; branch++)
|
||||
{
|
||||
await page.Locator("button[aria-label='Expand all areas']").ClickAsync();
|
||||
var load = page.GetByRole(AriaRole.Button, new() { Name = "Load instances", Exact = true });
|
||||
if (await load.CountAsync() == 0) return page;
|
||||
var pages = page.GetByText("Page 1", new() { Exact = true });
|
||||
var count = await pages.CountAsync();
|
||||
await load.First.ClickAsync();
|
||||
await Assertions.Expect(pages).ToHaveCountAsync(count + 1);
|
||||
}
|
||||
throw new InvalidOperationException("Topology fixture exceeded 300 branches.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ public class LoginTests
|
||||
const resp = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'username=baduser&password=badpass',
|
||||
body: 'username=baduser&password=badpass&__RequestVerificationToken=' + encodeURIComponent(document.querySelector('input[name=__RequestVerificationToken]').value),
|
||||
redirect: 'follow'
|
||||
});
|
||||
return resp.status;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests;
|
||||
|
||||
/// <summary>Read-only deployed smoke for CSRF login, bounded lazy topology, and debugger navigation.</summary>
|
||||
[Collection("Playwright")]
|
||||
public class LowReviewSmokeTests(PlaywrightFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task LowReview_CsrfLogin_LazyTopology_AndFlowDebuggerNavigation()
|
||||
{
|
||||
var page = await fixture.NewAuthenticatedPageAsync();
|
||||
var errors = new ConcurrentQueue<string>();
|
||||
page.PageError += (_, error) => errors.Enqueue(error);
|
||||
try
|
||||
{
|
||||
// Fixture fetch posts the real login form's antiforgery token with its cookie.
|
||||
Assert.DoesNotContain("/login", page.Url);
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/topology");
|
||||
await Assertions.Expect(page.GetByRole(AriaRole.Heading, new() { Name = "Topology", Exact = true })).ToBeVisibleAsync();
|
||||
var live = page.Locator("#live-updates");
|
||||
if (await live.IsCheckedAsync()) await live.UncheckAsync();
|
||||
await Assertions.Expect(page.Locator("i.bi-box")).ToHaveCountAsync(0);
|
||||
await Assertions.Expect(page.GetByText("0 loaded instance(s)", new() { Exact = false })).ToBeVisibleAsync();
|
||||
|
||||
var site = page.Locator("li[role='treeitem'][aria-level='1']").First;
|
||||
await Assertions.Expect(site).ToBeVisibleAsync();
|
||||
var toggle = site.Locator(":scope > .tv-row .tv-toggle").First;
|
||||
if (await toggle.GetAttributeAsync("aria-expanded") == "false") await toggle.ClickAsync();
|
||||
await site.GetByRole(AriaRole.Button, new() { Name = "Load instances", Exact = true }).First.ClickAsync();
|
||||
await Assertions.Expect(site.GetByText("Page 1", new() { Exact = true })).ToBeVisibleAsync();
|
||||
await Assertions.Expect(site.GetByRole(AriaRole.Button, new() { Name = "Previous", Exact = true })).ToBeDisabledAsync();
|
||||
await Assertions.Expect(site.GetByRole(AriaRole.Button, new() { Name = "Next", Exact = true })).ToBeVisibleAsync();
|
||||
Assert.InRange(await site.Locator("i.bi-box").CountAsync(), 0, 50);
|
||||
await Assertions.Expect(page.Locator("#blazor-error-ui")).ToBeHiddenAsync();
|
||||
await Assertions.Expect(page.Locator(".alert-danger")).ToHaveCountAsync(0);
|
||||
|
||||
await PlaywrightFixture.ExpandAllNavSectionsAsync(page);
|
||||
await page.GetByRole(AriaRole.Link, new() { Name = "Flow Debugger", Exact = true }).ClickAsync();
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/deployment/flow-debugger");
|
||||
await Assertions.Expect(page.GetByRole(AriaRole.Heading, new() { Name = "Flow Debugger", Exact = true })).ToBeVisibleAsync();
|
||||
await Assertions.Expect(page.Locator("#blazor-error-ui")).ToBeHiddenAsync();
|
||||
Assert.Empty(errors);
|
||||
}
|
||||
finally { await page.Context.CloseAsync(); }
|
||||
}
|
||||
}
|
||||
@@ -112,12 +112,17 @@ public class PlaywrightFixture : IAsyncLifetime
|
||||
// This sets the auth cookie in the browser context automatically.
|
||||
var finalUrl = await page.EvaluateAsync<string>(@"
|
||||
async ([u, p]) => {
|
||||
const token = document.querySelector('input[name=__RequestVerificationToken]');
|
||||
if (!token?.value) throw new Error('Login form did not render an antiforgery token.');
|
||||
const body = new URLSearchParams({ username: u, password: p });
|
||||
body.set(token.name, token.value);
|
||||
const resp = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'username=' + encodeURIComponent(u) + '&password=' + encodeURIComponent(p),
|
||||
body: body.toString(),
|
||||
redirect: 'follow'
|
||||
});
|
||||
if (!resp.ok) throw new Error('Login returned HTTP ' + resp.status);
|
||||
return resp.url;
|
||||
}
|
||||
", new object[] { username, password });
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
using NSubstitute;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
using Microsoft.AspNetCore.Antiforgery;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -67,16 +73,51 @@ public class AuthEndpointsCsrfTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PostAuthLogin_StillDisablesAntiforgery_PreAuthIsAcceptable()
|
||||
public async Task PostAuthLogin_ValidTokenAuthenticatesAndSetsCookie()
|
||||
{
|
||||
// Login is a pre-auth endpoint; disabling antiforgery there is acceptable
|
||||
// and intentional. This pins that the fix did not over-correct.
|
||||
var endpoints = BuildEndpoints();
|
||||
var login = Find(endpoints, "/auth/login", "POST");
|
||||
var endpoint = Find(BuildEndpoints(), "/auth/login", "POST")!;
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddAntiforgery();
|
||||
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
|
||||
services.AddSingleton(new LoginThrottle(TimeProvider.System, Options.Create(new SecurityOptions())));
|
||||
var ldap = Substitute.For<ILdapAuthService>();
|
||||
ldap.AuthenticateAsync("alice", "password", Arg.Any<CancellationToken>())
|
||||
.Returns(LdapAuthResult.Success("alice", "Alice", []));
|
||||
services.AddSingleton(ldap);
|
||||
var mapper = Substitute.For<IGroupRoleMapper<string>>();
|
||||
mapper.MapAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new GroupRoleMapping<string>([Roles.Viewer], Scope: null));
|
||||
services.AddSingleton(mapper);
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var tokenContext = new DefaultHttpContext { RequestServices = provider };
|
||||
var tokens = provider.GetRequiredService<IAntiforgery>().GetAndStoreTokens(tokenContext);
|
||||
var cookie = tokenContext.Response.Headers.SetCookie.Single()!.Split(';')[0];
|
||||
var context = new DefaultHttpContext { RequestServices = provider };
|
||||
context.Request.Headers.Cookie = cookie;
|
||||
context.Request.Method = "POST";
|
||||
context.Request.ContentType = "application/x-www-form-urlencoded";
|
||||
var form = $"username=alice&password=password&{tokens.FormFieldName}={Uri.EscapeDataString(tokens.RequestToken!)}";
|
||||
context.Request.Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(form));
|
||||
await endpoint.RequestDelegate!(context);
|
||||
Assert.Equal(StatusCodes.Status302Found, context.Response.StatusCode);
|
||||
Assert.Equal("/", context.Response.Headers.Location.ToString());
|
||||
Assert.Contains(context.Response.Headers.SetCookie, value => value!.StartsWith(".AspNetCore.Cookies="));
|
||||
}
|
||||
|
||||
Assert.NotNull(login);
|
||||
var antiforgery = login!.Metadata.GetMetadata<IAntiforgeryMetadata>();
|
||||
Assert.NotNull(antiforgery);
|
||||
Assert.False(antiforgery!.RequiresValidation);
|
||||
[Fact]
|
||||
public async Task PostAuthLogin_MissingTokenRejectsBeforeCredentialLookup()
|
||||
{
|
||||
var endpoint = Find(BuildEndpoints(), "/auth/login", "POST")!;
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddAntiforgery();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var context = new DefaultHttpContext { RequestServices = provider };
|
||||
context.Request.Method = "POST";
|
||||
context.Request.ContentType = "application/x-www-form-urlencoded";
|
||||
context.Request.Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("username=test&password=password"));
|
||||
await endpoint.RequestDelegate!(context);
|
||||
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Layout;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
|
||||
|
||||
public class RoutePolicyCoverageTests
|
||||
{
|
||||
[Fact]
|
||||
public void EveryRoutableComponent_HasItsReviewedPolicy_IncludingExternalSecrets()
|
||||
{
|
||||
var expected = new Dictionary<string, string>
|
||||
{
|
||||
["/"] = "authenticated",
|
||||
["/admin/api-keys"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/admin/api-keys/create"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/admin/api-keys/{KeyId}/edit"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/admin/ldap-mappings"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/admin/ldap-mappings/create"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/admin/ldap-mappings/{Id:int}/edit"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/admin/sites"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/admin/sites/create"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/admin/sites/{Id:int}/edit"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/audit/configuration"] = AuthorizationPolicies.OperationalAudit,
|
||||
["/audit/execution-tree"] = AuthorizationPolicies.OperationalAudit,
|
||||
["/audit/log"] = AuthorizationPolicies.OperationalAudit,
|
||||
["/deployment/debug-view"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/deployment/deployments"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/deployment/flow-debugger"] = AuthorizationPolicies.RequireFlowDebugger,
|
||||
["/deployment/instances"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/deployment/instances/create"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/deployment/instances/{Id:int}/configure"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/deployment/topology"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/design/api-methods/create"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/api-methods/{Id:int}/edit"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/connections"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/connections/create"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/connections/{Id:int}/certificates"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/design/connections/{Id:int}/edit"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/data-connections"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/data-connections/create"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/data-connections/{Id:int}/edit"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/db-connections/create"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/db-connections/{Id:int}/edit"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/external-systems"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/external-systems/create"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/external-systems/{Id:int}/edit"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/schema-library"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/shared-scripts"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/shared-scripts/create"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/shared-scripts/{Id:int}/edit"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/templates"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/templates/create"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/templates/{Id:int}"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/transport/export"] = AuthorizationPolicies.RequireDesign,
|
||||
["/design/transport/import"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/login"] = "anonymous",
|
||||
["/monitoring/alarms"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/monitoring/event-logs"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/monitoring/health"] = "authenticated",
|
||||
["/monitoring/parked-messages"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/notifications/kpis"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/notifications/lists"] = AuthorizationPolicies.RequireDesign,
|
||||
["/notifications/lists/create"] = AuthorizationPolicies.RequireDesign,
|
||||
["/notifications/lists/{Id:int}/edit"] = AuthorizationPolicies.RequireDesign,
|
||||
["/notifications/report"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/notifications/sms"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/notifications/smtp"] = AuthorizationPolicies.RequireAdmin,
|
||||
["/operations/secured-writes"] = AuthorizationPolicies.RequireSecuredWriteAccess,
|
||||
["/site-calls/report"] = AuthorizationPolicies.RequireDeployment,
|
||||
["/admin/secrets"] = "secrets:manage",
|
||||
};
|
||||
var actual = new Dictionary<string, string>();
|
||||
foreach (var type in new[] { typeof(MainLayout).Assembly, typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly }
|
||||
.SelectMany(a => a.GetTypes()))
|
||||
{
|
||||
foreach (var route in type.GetCustomAttributes<RouteAttribute>())
|
||||
{
|
||||
var anonymous = type.IsDefined(typeof(AllowAnonymousAttribute));
|
||||
var authorization = type.GetCustomAttributes<AuthorizeAttribute>().ToArray();
|
||||
Assert.True(anonymous || authorization.Length > 0, $"Missing authorization: {route.Template}");
|
||||
actual.Add(route.Template, anonymous ? "anonymous" : Assert.Single(authorization).Policy ?? "authenticated");
|
||||
}
|
||||
}
|
||||
Assert.Equal(expected.OrderBy(x => x.Key), actual.OrderBy(x => x.Key));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ internal static class ConfigurationMutationTestExtensions
|
||||
public static IServiceCollection AddTestConfigurationMutations(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddScoped<ConfigurationCommandService>();
|
||||
services.TryAddScoped<AuthorizedManualFailoverService>();
|
||||
services.TryAddSingleton<IAuditService>(_ => Substitute.For<IAuditService>());
|
||||
services.TryAddSingleton<IConfigurationCommandTransaction, RepositoryTransaction>();
|
||||
return services;
|
||||
|
||||
@@ -52,6 +52,17 @@ public class FlowDebuggerPageTests : BunitContext
|
||||
],
|
||||
[new("n1", "n2", false), new("n2", "n3", false)]);
|
||||
|
||||
[Fact]
|
||||
public void RefreshMerge_UsesReplayOrderThenLiveOnlyRows()
|
||||
{
|
||||
var first = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered);
|
||||
var late = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered);
|
||||
var last = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered);
|
||||
var liveOnly = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered);
|
||||
var result = FlowDebuggerPage.MergeEvents([first, last, liveOnly], [first, late, last]);
|
||||
Assert.Equal(new[] { first.EventId, late.EventId, last.EventId, liveOnly.EventId }, result.Select(e => e.EventId));
|
||||
}
|
||||
|
||||
private static FlowDebugOutcome GraphOutcome(bool withSite, bool withChildId)
|
||||
{
|
||||
var root = Guid.NewGuid();
|
||||
|
||||
+3
-1
@@ -96,7 +96,7 @@ public class TopologyDisableConfirmLabelTests : BunitContext
|
||||
new List<Site> { new("Plant-A", "plant-a") { Id = 1 } }));
|
||||
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template>()));
|
||||
_repo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
|
||||
_repo.GetTopologyInstancesAsync(1, null, 0, 50, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance>
|
||||
{
|
||||
new("Pump-001") { Id = 100, SiteId = 1, State = InstanceState.Enabled }
|
||||
@@ -133,6 +133,8 @@ public class TopologyDisableConfirmLabelTests : BunitContext
|
||||
{
|
||||
SeedOneEnabledInstance();
|
||||
var cut = RenderPageWithHost();
|
||||
cut.InvokeAsync(() => cut.FindComponent<TopologyPage>().Instance.LoadBranchAsync(1, null)).GetAwaiter().GetResult();
|
||||
cut.FindComponent<TopologyPage>().Render();
|
||||
|
||||
cut.FindAll(".tv-row").First(r => r.QuerySelector(".tv-label")?.TextContent == "Plant-A")
|
||||
.QuerySelector(".tv-toggle")!.Click();
|
||||
|
||||
@@ -74,6 +74,17 @@ public class NavMenuTests : BunitContext
|
||||
return host.FindComponent<NavMenu>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Viewer_HasFlowDebuggerAndNoEmptyNotificationsSection()
|
||||
{
|
||||
var cut = RenderWithRoles("Viewer");
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("/deployment/flow-debugger", cut.Markup);
|
||||
Assert.DoesNotContain(">Notifications<", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sections_RenderAsKitDetailsDisclosures_ExpandedByDefault()
|
||||
{
|
||||
|
||||
@@ -5,6 +5,16 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
|
||||
|
||||
public class PollGateTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RejectedRendererDispatch_ReleasesGateAndObservesFault()
|
||||
{
|
||||
var gate = new PollGate();
|
||||
Exception? observed = null;
|
||||
await gate.RunAsync(() => Task.FromException(new ObjectDisposedException("renderer")), ex => observed = ex);
|
||||
Assert.IsType<ObjectDisposedException>(observed);
|
||||
Assert.True(gate.TryEnter());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryEnter_SecondCallWhileHeld_ReturnsFalse()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
|
||||
|
||||
public class AuthorizedManualFailoverTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task RevokedAdmin_CannotDispatchCentralOrSite(bool site)
|
||||
{
|
||||
var auth = Substitute.For<AuthenticationStateProvider>();
|
||||
var identity = new ClaimsIdentity([new Claim(JwtTokenService.RoleClaimType, Roles.Administrator)], "test");
|
||||
auth.GetAuthenticationStateAsync().Returns(new AuthenticationState(new ClaimsPrincipal(identity)));
|
||||
var backend = Substitute.For<IManualFailoverService>();
|
||||
var service = new AuthorizedManualFailoverService(auth, backend);
|
||||
identity.RemoveClaim(identity.FindFirst(JwtTokenService.RoleClaimType)!);
|
||||
if (site) await Assert.ThrowsAsync<UnauthorizedAccessException>(() => service.FailOverSiteAsync("plant"));
|
||||
else await Assert.ThrowsAsync<UnauthorizedAccessException>(() => service.FailOverCentralAsync());
|
||||
await backend.DidNotReceiveWithAnyArgs().FailOverCentralAsync(default!);
|
||||
await backend.DidNotReceiveWithAnyArgs().FailOverSiteAsync(default!, default!);
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ public sealed class FlowDebugServiceTests : IDisposable
|
||||
};
|
||||
|
||||
private readonly ActorSystem _system = ActorSystem.Create("flow-debug-service-tests");
|
||||
private readonly ISiteRepository _sites = Substitute.For<ISiteRepository>();
|
||||
private readonly IInboundApiRepository _inboundApi = Substitute.For<IInboundApiRepository>();
|
||||
private readonly ITemplateEngineRepository _templates = Substitute.For<ITemplateEngineRepository>();
|
||||
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
|
||||
@@ -66,7 +67,7 @@ public sealed class FlowDebugServiceTests : IDisposable
|
||||
|
||||
public void Dispose() => _system.Terminate().Wait(TimeSpan.FromSeconds(5));
|
||||
|
||||
private FlowDebugService CreateService(object? reply, string role = "Deployer")
|
||||
private FlowDebugService CreateService(object? reply, string role = "Deployer", string? permittedSite = null)
|
||||
{
|
||||
var holder = new ManagementActorHolder();
|
||||
if (reply != null)
|
||||
@@ -77,7 +78,9 @@ public sealed class FlowDebugServiceTests : IDisposable
|
||||
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||
new Claim(JwtTokenService.RoleClaimType, role),
|
||||
};
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
var identity = new ClaimsIdentity(claims, "TestAuth");
|
||||
if (permittedSite is not null) identity.AddClaim(new Claim(JwtTokenService.SiteIdClaimType, permittedSite));
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var comms = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
@@ -85,7 +88,7 @@ public sealed class FlowDebugServiceTests : IDisposable
|
||||
comms.SetSiteCallAudit(_system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this))));
|
||||
comms.SetNotificationOutbox(_system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this))));
|
||||
|
||||
return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, _pipeline, comms, NullLogger<FlowDebugService>.Instance);
|
||||
return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, _pipeline, comms, NullLogger<FlowDebugService>.Instance, _sites);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -110,6 +113,33 @@ public sealed class FlowDebugServiceTests : IDisposable
|
||||
null, null, null, siteId, "node-a", "Sim01", "ERP.GetOrder", null,
|
||||
Lifecycle: null, CorrelationId: correlationId);
|
||||
|
||||
[Theory]
|
||||
[InlineData(42, true)]
|
||||
[InlineData(99, false)]
|
||||
public async Task ResolvedSite_ScopedCallerCanOnlyRelayItsGrant(int siteId, bool expected)
|
||||
{
|
||||
_sites.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new[]
|
||||
{
|
||||
new ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site("Plant", "plant") { Id = siteId }
|
||||
});
|
||||
var svc = CreateService(null, permittedSite: "42");
|
||||
var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, Guid.NewGuid(), "plant"));
|
||||
Assert.Equal(expected, result.Success);
|
||||
Assert.Equal(expected ? 1 : 0, _siteCallRequests.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task UnresolvedSite_ScopedCallerCannotRelay(bool retry)
|
||||
{
|
||||
var svc = CreateService(null, permittedSite: "42");
|
||||
var step = OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, Guid.NewGuid(), "unknown-site");
|
||||
var result = retry ? await svc.RetryOutboundAsync(step) : await svc.DiscardOutboundAsync(step);
|
||||
Assert.False(result.Success);
|
||||
Assert.Empty(_siteCallRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryOutbound_ApiCallParked_RelaysRetrySiteCallWithCorrelationAndSite()
|
||||
{
|
||||
|
||||
+35
@@ -25,6 +25,41 @@ public class TemplateFolderTreeRevealKeysTests : BunitContext
|
||||
return (new() { a, b, other }, new() { t7, t8 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtraNodeMetadataChange_RebuildsAndSurvivesFiltering()
|
||||
{
|
||||
var template = new Template("Template") { Id = 1 };
|
||||
var inherited = false;
|
||||
IReadOnlyList<TemplateTreeNode> Children(Template _) => [new()
|
||||
{
|
||||
Kind = TemplateTreeNodeKind.Composition, Id = 2, Name = "Slot",
|
||||
IsInherited = inherited, KeyOverride = inherited ? "inherited:2" : "local:2"
|
||||
}];
|
||||
var cut = Render<TemplateFolderTree>(p => p.Add(x => x.Templates, new[] { template })
|
||||
.Add(x => x.ExtraTemplateChildren, Children).Add(x => x.Filter, "Slot"));
|
||||
inherited = true;
|
||||
cut.Render(p => p.Add(x => x.Filter, "Slot"));
|
||||
var roots = cut.FindComponent<TreeView<TemplateTreeNode>>().Instance.Items;
|
||||
var node = Assert.Single(Assert.Single(roots).Children);
|
||||
Assert.True(node.IsInherited);
|
||||
Assert.Equal("inherited:2", node.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedParameters_PreserveForest_ButInPlaceRenameRebuilds()
|
||||
{
|
||||
var (folders, templates) = Catalogue();
|
||||
var cut = Render<TemplateFolderTree>(p => p.Add(x => x.Folders, folders).Add(x => x.Templates, templates));
|
||||
var field = typeof(TemplateFolderTree).GetField("_allRoots", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
||||
var first = field.GetValue(cut.Instance);
|
||||
cut.Render(p => p.Add(x => x.Folders, folders).Add(x => x.Templates, templates));
|
||||
Assert.Same(first, field.GetValue(cut.Instance));
|
||||
folders[0].Name = "Renamed";
|
||||
cut.Render(p => p.Add(x => x.Folders, folders).Add(x => x.Templates, templates));
|
||||
Assert.NotSame(first, field.GetValue(cut.Instance));
|
||||
Assert.Contains("Renamed", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithoutRevealKeys_AllFoldersStartCollapsed()
|
||||
{
|
||||
|
||||
@@ -125,6 +125,9 @@ public class TopologyPageTests : BunitContext
|
||||
.Returns(Task.FromResult<IReadOnlyList<Template>>(templates?.ToList() ?? new List<Template>()));
|
||||
_repo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Instance>>(instances?.ToList() ?? new List<Instance>()));
|
||||
_repo.GetTopologyInstancesAsync(Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(call => (IReadOnlyList<Instance>)(instances ?? []).Where(i => i.SiteId == call.ArgAt<int>(0) && i.AreaId == call.ArgAt<int?>(1))
|
||||
.Skip(call.ArgAt<int>(2)).Take(call.ArgAt<int>(3)).ToList());
|
||||
|
||||
areasBySite ??= new();
|
||||
_repo.GetAreasBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
@@ -137,6 +140,94 @@ public class TopologyPageTests : BunitContext
|
||||
});
|
||||
}
|
||||
|
||||
private IRenderedComponent<TopologyPage> RenderLoadedTopology()
|
||||
{
|
||||
var cut = Render<TopologyPage>();
|
||||
cut.InvokeAsync(async () =>
|
||||
{
|
||||
foreach (var site in await _siteRepo.GetAllSitesAsync())
|
||||
{
|
||||
await cut.Instance.LoadBranchAsync(site.Id, null);
|
||||
foreach (var area in await _repo.GetAreasBySiteIdAsync(site.Id))
|
||||
await cut.Instance.LoadBranchAsync(site.Id, area.Id);
|
||||
}
|
||||
cut.Render();
|
||||
}).GetAwaiter().GetResult();
|
||||
return cut;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task BranchAndRefresh_SerializeRepositoryReadsWithoutDroppingUserPage(bool branchFirst)
|
||||
{
|
||||
SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }],
|
||||
instances: [new Instance("First") { Id = 1, SiteId = 1 }]);
|
||||
var cut = Render<TopologyPage>();
|
||||
await cut.InvokeAsync(() => cut.Instance.LoadBranchAsync(1, null));
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource<IReadOnlyList<Instance>>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var calls = 0;
|
||||
_repo.GetTopologyInstancesAsync(1, null, Arg.Any<int>(), 50, Arg.Any<CancellationToken>())
|
||||
.Returns(call =>
|
||||
{
|
||||
if (Interlocked.Increment(ref calls) == 1)
|
||||
{
|
||||
entered.TrySetResult();
|
||||
return release.Task;
|
||||
}
|
||||
return Task.FromResult<IReadOnlyList<Instance>>([new Instance("Next") { Id = 51, SiteId = 1 }]);
|
||||
});
|
||||
var first = cut.InvokeAsync(() => branchFirst
|
||||
? cut.Instance.LoadBranchAsync(1, null, 50) : cut.Instance.LoadDeployedStateAsync());
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
Task second = Task.CompletedTask;
|
||||
await cut.InvokeAsync(() =>
|
||||
{
|
||||
second = branchFirst ? cut.Instance.LoadDeployedStateAsync() : cut.Instance.LoadBranchAsync(1, null, 50);
|
||||
});
|
||||
try
|
||||
{
|
||||
Assert.False(second.IsCompleted);
|
||||
Assert.Equal(1, Volatile.Read(ref calls));
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.TrySetResult([new Instance("Next") { Id = 51, SiteId = 1 }]);
|
||||
await Task.WhenAll(first, second).WaitAsync(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
Assert.Equal(2, calls);
|
||||
var field = typeof(TopologyPage).GetField("_allInstances", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
|
||||
Assert.Equal(51, Assert.Single((List<Instance>)field.GetValue(cut.Instance)!).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BranchPages_AreLazyAndReplacePreviousPage()
|
||||
{
|
||||
SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }],
|
||||
instances: Enumerable.Range(1, 101).Select(id => new Instance($"Pump-{id}") { Id = id, SiteId = 1 }));
|
||||
var cut = Render<TopologyPage>();
|
||||
await cut.InvokeAsync(() => cut.Instance.LoadBranchAsync(1, null));
|
||||
var field = typeof(TopologyPage).GetField("_allInstances", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
|
||||
Assert.Equal(50, ((List<Instance>)field.GetValue(cut.Instance)!).Count);
|
||||
await cut.InvokeAsync(() => cut.Instance.LoadBranchAsync(1, null, 50));
|
||||
var second = (List<Instance>)field.GetValue(cut.Instance)!;
|
||||
Assert.Equal(50, second.Count);
|
||||
Assert.Equal(51, second[0].Id);
|
||||
await _repo.DidNotReceive().GetAllInstancesAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitialLoad_DoesNotReadInstancesOrTemplates()
|
||||
{
|
||||
SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }]);
|
||||
_repo.ClearReceivedCalls();
|
||||
var cut = Render<TopologyPage>();
|
||||
cut.WaitForAssertion(() => Assert.Contains("Plant", cut.Markup));
|
||||
_repo.DidNotReceive().GetAllInstancesAsync(Arg.Any<CancellationToken>());
|
||||
_repo.DidNotReceive().GetAllTemplatesAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
@@ -145,7 +236,7 @@ public class TopologyPageTests : BunitContext
|
||||
var instance = new Instance("Pump") { Id = 100, SiteId = 1, AreaId = 10 };
|
||||
SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }], instances: [instance]);
|
||||
_repo.GetInstanceByIdAsync(100, Arg.Any<CancellationToken>()).Returns(instance);
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
var auth = Services.GetRequiredService<AuthenticationStateProvider>();
|
||||
var identity = (ClaimsIdentity)(await auth.GetAuthenticationStateAsync()).User.Identity!;
|
||||
if (revoked) identity.AddClaim(new Claim(JwtTokenService.SiteIdClaimType, "2"));
|
||||
@@ -164,7 +255,7 @@ public class TopologyPageTests : BunitContext
|
||||
public async Task UI13_ExistingTopologyCreateUsesCurrentSiteScope(bool revoked)
|
||||
{
|
||||
SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }]);
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
var auth = Services.GetRequiredService<AuthenticationStateProvider>();
|
||||
var identity = (ClaimsIdentity)(await auth.GetAuthenticationStateAsync()).User.Identity!;
|
||||
if (revoked) identity.AddClaim(new Claim(JwtTokenService.SiteIdClaimType, "2"));
|
||||
@@ -181,7 +272,7 @@ public class TopologyPageTests : BunitContext
|
||||
{
|
||||
SeedRepos();
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
|
||||
Assert.Contains("No sites configured", cut.Markup);
|
||||
}
|
||||
@@ -193,7 +284,7 @@ public class TopologyPageTests : BunitContext
|
||||
// under it must still appear so users can move/create into it.
|
||||
SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } });
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
|
||||
Assert.Contains("Plant-A", cut.Markup);
|
||||
Assert.Contains("bi-building", cut.Markup);
|
||||
@@ -222,7 +313,7 @@ public class TopologyPageTests : BunitContext
|
||||
},
|
||||
areasBySite: areasBySite);
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
|
||||
// Expand the site, then the area, to render the instance leaf. The
|
||||
// helper scopes by the row's own label so we don't match outer rows
|
||||
@@ -251,7 +342,7 @@ public class TopologyPageTests : BunitContext
|
||||
sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
|
||||
areasBySite: areasBySite);
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
FindToggleForLabel(cut, "Plant-A")!.Click();
|
||||
|
||||
var search = cut.Find("input[type='text']");
|
||||
@@ -287,7 +378,7 @@ public class TopologyPageTests : BunitContext
|
||||
new Site("Plant-B", "plant-b") { Id = 2 },
|
||||
});
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
|
||||
// The permitted site is rendered; the non-permitted site is not.
|
||||
Assert.Contains("Plant-A", cut.Markup);
|
||||
@@ -304,7 +395,7 @@ public class TopologyPageTests : BunitContext
|
||||
new Site("Plant-B", "plant-b") { Id = 2 },
|
||||
});
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
|
||||
Assert.Contains("Plant-A", cut.Markup);
|
||||
Assert.Contains("Plant-B", cut.Markup);
|
||||
@@ -319,7 +410,7 @@ public class TopologyPageTests : BunitContext
|
||||
};
|
||||
SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } }, areasBySite: areasBySite);
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
FindToggleForLabel(cut, "Plant-A")!.Click();
|
||||
|
||||
var areaLabel = cut.FindAll("span.tv-label").First(s => s.TextContent == "Line-1");
|
||||
@@ -342,7 +433,7 @@ public class TopologyPageTests : BunitContext
|
||||
new Instance("Pump-001") { Id = 100, SiteId = 1, State = InstanceState.NotDeployed }
|
||||
});
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
FindToggleForLabel(cut, "Plant-A")!.Click();
|
||||
|
||||
var instanceLabel = cut.FindAll("span.tv-label").First(s => s.TextContent == "Pump-001");
|
||||
@@ -410,7 +501,7 @@ public class TopologyPageTests : BunitContext
|
||||
.Returns(Task.FromResult(Result<FlatteningPipelineResult>.Success(
|
||||
new FlatteningPipelineResult(currentConfig, "hash-new", ValidationResult.Success()))));
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
FindToggleForLabel(cut, "Plant-A")!.Click();
|
||||
FindToggleForLabel(cut, "Line-1")!.Click();
|
||||
|
||||
@@ -472,7 +563,7 @@ public class TopologyPageTests : BunitContext
|
||||
// call count is the proxy for "did staleness recompute".
|
||||
SeedOneDeployedInstanceWithComparison();
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
|
||||
// Initial load computed staleness exactly once.
|
||||
await _deployRepo.Received(1)
|
||||
@@ -493,7 +584,7 @@ public class TopologyPageTests : BunitContext
|
||||
// cheap deployed state (hierarchy + State badge) must still render.
|
||||
SeedOneDeployedInstanceWithComparison();
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
FindToggleForLabel(cut, "Plant-A")!.Click();
|
||||
|
||||
Assert.Contains("Pump-001", cut.Markup);
|
||||
@@ -510,7 +601,7 @@ public class TopologyPageTests : BunitContext
|
||||
// again — a second snapshot-repository call.
|
||||
SeedOneDeployedInstanceWithComparison();
|
||||
|
||||
var cut = Render<TopologyPage>();
|
||||
var cut = RenderLoadedTopology();
|
||||
_deployRepo.Received(1)
|
||||
.GetDeployedSnapshotByInstanceIdAsync(100, Arg.Any<CancellationToken>());
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging;
|
||||
|
||||
public class FlowEnumContractTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnumNamesAndValues_RemainStable()
|
||||
{
|
||||
Assert.Equal(new[] { "Central", "Site", "Tags", "External" }, Enum.GetNames<FlowLane>());
|
||||
Assert.Equal(Enumerable.Range(0, 4), Enum.GetValues<FlowLane>().Select(x => (int)x));
|
||||
Assert.Equal(new[] { "InboundRequest", "RouteCall", "ScriptRun", "AttributeWrite", "AttributeWait", "Response", "ApiCall", "DbCall", "Notify", "Unknown" }, Enum.GetNames<FlowStepKind>());
|
||||
Assert.Equal(Enumerable.Range(0, 10), Enum.GetValues<FlowStepKind>().Select(x => (int)x));
|
||||
Assert.Equal(new[] { "Ok", "Failed", "TimedOut", "Predicted", "NotTraced", "Pending", "Parked" }, Enum.GetNames<FlowStepOutcome>());
|
||||
Assert.Equal(Enumerable.Range(0, 7), Enum.GetValues<FlowStepOutcome>().Select(x => (int)x));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KindRank_UsesDisplayOrderIndependentOfEnumValue()
|
||||
{
|
||||
Assert.Equal(new[] { 0, 1, 2, 3, 4, 9, 5, 5, 5, 6 }, Enum.GetValues<FlowStepKind>().Select(FlowGraphBuilder.KindRank));
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,35 @@ public class FlowGraphBuilderTests
|
||||
return [inbound, route, wait, run, write2, write1, cascade];
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InferredDeviceCascade_IsDashed()
|
||||
{
|
||||
var parent = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite,
|
||||
AuditStatus.Delivered, executionId: Root);
|
||||
var child = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun,
|
||||
AuditStatus.Delivered, executionId: SiteRun, parentExecutionId: Root,
|
||||
extra: new ScriptTraceExtra { TriggerSource = "inferred-device-update" }.ToJson());
|
||||
var edge = Assert.Single(FlowGraphBuilder.Build([parent, child]).Edges);
|
||||
Assert.True(edge.Spawn);
|
||||
Assert.True(edge.External);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "terminal stack")]
|
||||
[InlineData("old stack", null)]
|
||||
public void Fold_UsesLatestErrorDetail(string? initialDetail, string? finalDetail)
|
||||
{
|
||||
var correlation = Guid.NewGuid();
|
||||
var first = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedSubmit,
|
||||
AuditStatus.Submitted, occurredAtUtc: T0, correlationId: correlation, errorDetail: initialDetail);
|
||||
var last = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedResolve,
|
||||
AuditStatus.Failed, occurredAtUtc: T0.AddSeconds(1), correlationId: correlation, errorDetail: finalDetail);
|
||||
var step = Assert.Single(FlowGraphBuilder.Build([first, last]).Steps);
|
||||
Assert.Equal(finalDetail, step.ErrorDetail);
|
||||
Assert.Equal(initialDetail, step.Lifecycle![0].ErrorDetail);
|
||||
Assert.Equal(finalDetail, step.Lifecycle[1].ErrorDetail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DelmiaShape_ProjectsLanes_Edges_AndSynthesizedResponse()
|
||||
{
|
||||
@@ -90,6 +119,7 @@ public class FlowGraphBuilderTests
|
||||
var external = Assert.Single(graph.Edges, e => e.External);
|
||||
Assert.Equal(graph.Steps[3].EventId, external.FromEventId);
|
||||
Assert.Equal(graph.Steps[4].EventId, external.ToEventId);
|
||||
Assert.Equal(graph.Edges.Count, graph.Edges.Select(e => (e.FromEventId, e.ToEventId)).Distinct().Count());
|
||||
|
||||
// The cascade run hangs off the write that spawned it (same site, no boundary) — a Spawn edge (D9);
|
||||
// the route → run edge and every in-chain edge are not.
|
||||
|
||||
+58
@@ -19,6 +19,64 @@ public class SitePairChannelProviderDisposalTests
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<SitePairChannelProvider>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task Shutdown_RetainsActiveChannelThenDisposesItAtCompletion()
|
||||
{
|
||||
using var provider = Build();
|
||||
provider.UpdateSite("site-1", "http://localhost:15101", null);
|
||||
var entered = new TaskCompletionSource<GrpcChannel>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var call = provider.ExecuteAsync("site-1", async (channel, ct) =>
|
||||
{
|
||||
entered.SetResult(channel);
|
||||
await release.Task;
|
||||
_ = channel.CreateCallInvoker();
|
||||
return 42;
|
||||
}, CancellationToken.None);
|
||||
var channel = await entered.Task;
|
||||
try
|
||||
{
|
||||
provider.Dispose();
|
||||
Assert.Equal(1, provider.ParkedChannelCount);
|
||||
_ = channel.CreateCallInvoker();
|
||||
Assert.Throws<ObjectDisposedException>(() => provider.UpdateSite("site-2", "http://localhost:15102", null));
|
||||
}
|
||||
finally { release.TrySetResult(); }
|
||||
Assert.Equal(42, await call);
|
||||
Assert.Equal(0, provider.ParkedChannelCount);
|
||||
Assert.Throws<ObjectDisposedException>(() => channel.CreateCallInvoker());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetiredChannel_SweepKeepsItAliveUntilActualCallCompletion()
|
||||
{
|
||||
using var provider = Build();
|
||||
provider.UpdateSite("site-1", "http://localhost:15101", null);
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
GrpcChannel? held = null;
|
||||
var call = provider.ExecuteAsync("site-1", async (channel, ct) =>
|
||||
{
|
||||
held = channel;
|
||||
entered.SetResult();
|
||||
await release.Task;
|
||||
return 42;
|
||||
}, CancellationToken.None);
|
||||
try
|
||||
{
|
||||
await entered.Task;
|
||||
provider.UpdateSite("site-1", "http://localhost:15102", null);
|
||||
// Simulates any elapsed grace, including waits longer than five minutes.
|
||||
provider.SweepGraveyard(force: true);
|
||||
Assert.Equal(1, provider.ParkedChannelCount);
|
||||
_ = held!.CreateCallInvoker();
|
||||
}
|
||||
finally { release.TrySetResult(); await call; }
|
||||
provider.SweepGraveyard(force: true);
|
||||
Assert.Equal(0, provider.ParkedChannelCount);
|
||||
Assert.Throws<ObjectDisposedException>(() => held!.CreateCallInvoker());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyThenUnavailable_DoesNotReplayAnAmbiguousOperation()
|
||||
{
|
||||
|
||||
@@ -8,6 +8,15 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
public class SiteStreamGrpcClientTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConnectedHeaderTimeout_IsImmutablePerClient()
|
||||
{
|
||||
var property = typeof(SiteStreamGrpcClient).GetProperty("ConnectedHeaderTimeout",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Static)!;
|
||||
Assert.False(property.GetMethod!.IsStatic);
|
||||
Assert.Null(property.SetMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToDomainEvent_AttributeChanged_MapsCorrectly()
|
||||
{
|
||||
@@ -436,32 +445,23 @@ public class SiteStreamGrpcClientTests
|
||||
// The bug: a header TIMEOUT used to be reported as connected. An unreachable/wedged
|
||||
// site produces exactly that shape, so the aggregator cleared _streamDown, consumed
|
||||
// its pending re-seed and fanned a full snapshot out at a site that never answered.
|
||||
var previous = SiteStreamGrpcClient.ConnectedHeaderTimeout;
|
||||
SiteStreamGrpcClient.ConnectedHeaderTimeout = TimeSpan.FromMilliseconds(50);
|
||||
try
|
||||
{
|
||||
var client = SiteStreamGrpcClient.CreateForTesting();
|
||||
var cts = new CancellationTokenSource();
|
||||
var connected = 0;
|
||||
var neverArrives = new TaskCompletionSource<Metadata>();
|
||||
var client = SiteStreamGrpcClient.CreateForTesting(new CommunicationOptions { ConnectedHeaderTimeout = TimeSpan.FromMilliseconds(50) });
|
||||
var cts = new CancellationTokenSource();
|
||||
var connected = 0;
|
||||
var neverArrives = new TaskCompletionSource<Metadata>();
|
||||
|
||||
// No headers AND no events: the site is dead. The stream ends (status OK) with
|
||||
// no connected signal ever raised.
|
||||
await client.ConsumeStreamAsync(
|
||||
"corr-dead",
|
||||
cts,
|
||||
() => FakeCall(new StubStreamReader(), neverArrives.Task),
|
||||
_ => { },
|
||||
_ => { },
|
||||
() => { },
|
||||
() => connected++);
|
||||
// No headers AND no events: the site is dead. The stream ends (status OK) with
|
||||
// no connected signal ever raised.
|
||||
await client.ConsumeStreamAsync(
|
||||
"corr-dead",
|
||||
cts,
|
||||
() => FakeCall(new StubStreamReader(), neverArrives.Task),
|
||||
_ => { },
|
||||
_ => { },
|
||||
() => { },
|
||||
() => connected++);
|
||||
|
||||
Assert.Equal(0, connected);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SiteStreamGrpcClient.ConnectedHeaderTimeout = previous;
|
||||
}
|
||||
Assert.Equal(0, connected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -470,42 +470,33 @@ public class SiteStreamGrpcClientTests
|
||||
// The case the timeout was originally added for — a peer that defers its headers
|
||||
// until the first message — is now covered by the event itself, which is real proof
|
||||
// of a live peer. Connected must precede the event's delivery and fire only once.
|
||||
var previous = SiteStreamGrpcClient.ConnectedHeaderTimeout;
|
||||
SiteStreamGrpcClient.ConnectedHeaderTimeout = TimeSpan.FromMilliseconds(50);
|
||||
try
|
||||
{
|
||||
var client = SiteStreamGrpcClient.CreateForTesting();
|
||||
var cts = new CancellationTokenSource();
|
||||
var connected = 0;
|
||||
var connectedBeforeFirstEvent = false;
|
||||
var events = 0;
|
||||
var neverArrives = new TaskCompletionSource<Metadata>();
|
||||
var client = SiteStreamGrpcClient.CreateForTesting(new CommunicationOptions { ConnectedHeaderTimeout = TimeSpan.FromMilliseconds(50) });
|
||||
var cts = new CancellationTokenSource();
|
||||
var connected = 0;
|
||||
var connectedBeforeFirstEvent = false;
|
||||
var events = 0;
|
||||
var neverArrives = new TaskCompletionSource<Metadata>();
|
||||
|
||||
await client.ConsumeStreamAsync(
|
||||
"corr-late-headers",
|
||||
cts,
|
||||
() => FakeCall(
|
||||
new StubStreamReader(
|
||||
new SiteStreamEvent { CorrelationId = "corr-late-headers" },
|
||||
new SiteStreamEvent { CorrelationId = "corr-late-headers" }),
|
||||
neverArrives.Task),
|
||||
_ =>
|
||||
{
|
||||
if (events == 0) connectedBeforeFirstEvent = connected == 1;
|
||||
events++;
|
||||
},
|
||||
_ => { },
|
||||
() => { },
|
||||
() => connected++);
|
||||
await client.ConsumeStreamAsync(
|
||||
"corr-late-headers",
|
||||
cts,
|
||||
() => FakeCall(
|
||||
new StubStreamReader(
|
||||
new SiteStreamEvent { CorrelationId = "corr-late-headers" },
|
||||
new SiteStreamEvent { CorrelationId = "corr-late-headers" }),
|
||||
neverArrives.Task),
|
||||
_ =>
|
||||
{
|
||||
if (events == 0) connectedBeforeFirstEvent = connected == 1;
|
||||
events++;
|
||||
},
|
||||
_ => { },
|
||||
() => { },
|
||||
() => connected++);
|
||||
|
||||
Assert.Equal(2, events);
|
||||
Assert.Equal(1, connected);
|
||||
Assert.True(connectedBeforeFirstEvent);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SiteStreamGrpcClient.ConnectedHeaderTimeout = previous;
|
||||
}
|
||||
Assert.Equal(2, events);
|
||||
Assert.Equal(1, connected);
|
||||
Assert.True(connectedBeforeFirstEvent);
|
||||
}
|
||||
|
||||
// ── R2: batch unpacking on the client ───────────────────────────────────────
|
||||
@@ -514,12 +505,12 @@ public class SiteStreamGrpcClientTests
|
||||
{
|
||||
AttributeChanged = new AttributeValueUpdate
|
||||
{
|
||||
InstanceUniqueName = "SiteA.Pump01",
|
||||
AttributePath = "Modules.IO",
|
||||
AttributeName = "Seq",
|
||||
Value = value,
|
||||
Quality = Quality.Good,
|
||||
Timestamp = Timestamp.FromDateTimeOffset(ts)
|
||||
InstanceUniqueName = "SiteA.Pump01",
|
||||
AttributePath = "Modules.IO",
|
||||
AttributeName = "Seq",
|
||||
Value = value,
|
||||
Quality = Quality.Good,
|
||||
Timestamp = Timestamp.FromDateTimeOffset(ts)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
public class LowReviewTransportTests
|
||||
{
|
||||
private static SiteStreamGrpcServer Server() => new(
|
||||
Substitute.For<ISiteStreamSubscriber>(), NullLogger<SiteStreamGrpcServer>.Instance);
|
||||
|
||||
[Theory]
|
||||
[InlineData(2001)]
|
||||
[InlineData(int.MaxValue)]
|
||||
public async Task Pulls_RejectOversizedRowRequestsBeforeReading(int size)
|
||||
{
|
||||
var server = Server();
|
||||
var queue = Substitute.For<ISiteAuditQueue>();
|
||||
var store = Substitute.For<IOperationTrackingStore>();
|
||||
server.SetSiteAuditQueue(queue);
|
||||
server.SetOperationTrackingStore(store);
|
||||
var context = Substitute.For<ServerCallContext>();
|
||||
var audit = await Assert.ThrowsAsync<RpcException>(() => server.PullAuditEvents(
|
||||
new PullAuditEventsRequest { BatchSize = size }, context));
|
||||
var calls = await Assert.ThrowsAsync<RpcException>(() => server.PullSiteCalls(
|
||||
new PullSiteCallsRequest { BatchSize = size }, context));
|
||||
Assert.Equal(StatusCode.InvalidArgument, audit.StatusCode);
|
||||
Assert.Equal(StatusCode.InvalidArgument, calls.StatusCode);
|
||||
Assert.Empty(queue.ReceivedCalls());
|
||||
Assert.Empty(store.ReceivedCalls());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetiredSiteIngestRpcs_ReturnUnimplementedEvenForEmptyBatches()
|
||||
{
|
||||
var server = Server();
|
||||
var context = Substitute.For<ServerCallContext>();
|
||||
var audit = await Assert.ThrowsAsync<RpcException>(() => server.IngestAuditEvents(new(), context));
|
||||
var cached = await Assert.ThrowsAsync<RpcException>(() => server.IngestCachedTelemetry(new(), context));
|
||||
Assert.Equal(StatusCode.Unimplemented, audit.StatusCode);
|
||||
Assert.Equal(StatusCode.Unimplemented, cached.StatusCode);
|
||||
}
|
||||
}
|
||||
+13
-89
@@ -1,100 +1,24 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle D D2 tests for <see cref="SiteStreamGrpcServer.IngestAuditEvents"/>.
|
||||
/// Verifies the DTO→entity→actor→ack round-trip through the gRPC handler.
|
||||
/// A tiny <c>StubIngestActor</c> stands in for the central
|
||||
/// <c>AuditLogIngestActor</c>, replying with the EventIds it received so the
|
||||
/// test asserts the wiring without depending on MSSQL.
|
||||
/// </summary>
|
||||
public class SiteStreamIngestAuditEventsTests : TestKit
|
||||
public class SiteStreamIngestAuditEventsTests
|
||||
{
|
||||
private readonly ISiteStreamSubscriber _subscriber = Substitute.For<ISiteStreamSubscriber>();
|
||||
|
||||
private SiteStreamGrpcServer CreateServer() =>
|
||||
new(_subscriber, NullLogger<SiteStreamGrpcServer>.Instance);
|
||||
|
||||
private static ServerCallContext NewContext(CancellationToken ct = default)
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task RetiredMethodRejectsEmptyAndNonEmptyBatches(bool nonEmpty)
|
||||
{
|
||||
var context = Substitute.For<ServerCallContext>();
|
||||
context.CancellationToken.Returns(ct);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static AuditEventDto NewDto(Guid? id = null) => new()
|
||||
{
|
||||
EventId = (id ?? Guid.NewGuid()).ToString(),
|
||||
OccurredAtUtc = Timestamp.FromDateTime(
|
||||
DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)),
|
||||
Channel = "ApiOutbound",
|
||||
Kind = "ApiCall",
|
||||
Status = "Delivered",
|
||||
SourceSiteId = "site-1",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAuditEvents_With_AuditIngestActor_Routes_To_Actor_Returns_Reply()
|
||||
{
|
||||
// Arrange — a stub actor that echoes every received EventId back.
|
||||
var stubActor = Sys.ActorOf(Props.Create(() => new EchoIngestActor()));
|
||||
|
||||
var server = CreateServer();
|
||||
server.SetAuditIngestActor(stubActor);
|
||||
|
||||
// Build a 3-event batch.
|
||||
var dtos = Enumerable.Range(0, 3).Select(_ => NewDto()).ToList();
|
||||
var batch = new AuditEventBatch();
|
||||
batch.Events.AddRange(dtos);
|
||||
|
||||
// Act
|
||||
var ack = await server.IngestAuditEvents(batch, NewContext());
|
||||
|
||||
// Assert — every dto's id appears in the ack, demonstrating end-to-
|
||||
// end routing through the actor.
|
||||
Assert.Equal(3, ack.AcceptedEventIds.Count);
|
||||
var expectedIds = dtos.Select(d => d.EventId).ToHashSet();
|
||||
Assert.True(expectedIds.SetEquals(ack.AcceptedEventIds.ToHashSet()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAuditEvents_NoActor_Wired_ReturnsEmptyAck()
|
||||
{
|
||||
var server = CreateServer();
|
||||
// Intentionally do NOT call SetAuditIngestActor — simulates host
|
||||
// startup race window.
|
||||
|
||||
var batch = new AuditEventBatch();
|
||||
batch.Events.Add(NewDto());
|
||||
|
||||
var ack = await server.IngestAuditEvents(batch, NewContext());
|
||||
|
||||
Assert.Empty(ack.AcceptedEventIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tiny ReceiveActor that echoes every EventId in an incoming
|
||||
/// <see cref="IngestAuditEventsCommand"/> back as an
|
||||
/// <see cref="IngestAuditEventsReply"/>. Stands in for the central
|
||||
/// AuditLogIngestActor so this test never touches MSSQL.
|
||||
/// </summary>
|
||||
private sealed class EchoIngestActor : ReceiveActor
|
||||
{
|
||||
public EchoIngestActor()
|
||||
{
|
||||
Receive<IngestAuditEventsCommand>(cmd =>
|
||||
{
|
||||
var ids = cmd.Events.Select(e => e.EventId).ToList();
|
||||
Sender.Tell(new IngestAuditEventsReply(ids));
|
||||
});
|
||||
}
|
||||
var server = new SiteStreamGrpcServer(Substitute.For<ISiteStreamSubscriber>(),
|
||||
NullLogger<SiteStreamGrpcServer>.Instance);
|
||||
var request = new AuditEventBatch();
|
||||
if (nonEmpty) request.Events.Add(new AuditEventDto());
|
||||
var error = await Assert.ThrowsAsync<RpcException>(() => server.IngestAuditEvents(
|
||||
request, Substitute.For<ServerCallContext>()));
|
||||
Assert.Equal(StatusCode.Unimplemented, error.StatusCode);
|
||||
Assert.Contains("CentralControlService", error.Status.Detail);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-110
@@ -1,121 +1,24 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle D D2 tests for <see cref="SiteStreamGrpcServer.IngestCachedTelemetry"/>.
|
||||
/// Verifies the DTO→entity→actor→ack round-trip through the gRPC handler. A
|
||||
/// tiny <c>EchoCachedIngestActor</c> stands in for the central
|
||||
/// <c>AuditLogIngestActor</c>, replying with the EventIds it received so the
|
||||
/// test asserts the wiring without depending on MSSQL.
|
||||
/// </summary>
|
||||
public class SiteStreamIngestCachedTelemetryTests : TestKit
|
||||
public class SiteStreamIngestCachedTelemetryTests
|
||||
{
|
||||
private readonly ISiteStreamSubscriber _subscriber = Substitute.For<ISiteStreamSubscriber>();
|
||||
|
||||
private SiteStreamGrpcServer CreateServer() =>
|
||||
new(_subscriber, NullLogger<SiteStreamGrpcServer>.Instance);
|
||||
|
||||
private static ServerCallContext NewContext(CancellationToken ct = default)
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task RetiredMethodRejectsEmptyAndNonEmptyBatches(bool nonEmpty)
|
||||
{
|
||||
var context = Substitute.For<ServerCallContext>();
|
||||
context.CancellationToken.Returns(ct);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static CachedTelemetryPacket NewPacket(Guid? eventId = null, Guid? trackedId = null)
|
||||
{
|
||||
var now = Timestamp.FromDateTime(
|
||||
DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc));
|
||||
return new CachedTelemetryPacket
|
||||
{
|
||||
AuditEvent = new AuditEventDto
|
||||
{
|
||||
EventId = (eventId ?? Guid.NewGuid()).ToString(),
|
||||
OccurredAtUtc = now,
|
||||
Channel = "ApiOutbound",
|
||||
Kind = "CachedSubmit",
|
||||
Status = "Submitted",
|
||||
SourceSiteId = "site-1",
|
||||
CorrelationId = (trackedId ?? Guid.NewGuid()).ToString(),
|
||||
},
|
||||
Operational = new SiteCallOperationalDto
|
||||
{
|
||||
TrackedOperationId = (trackedId ?? Guid.NewGuid()).ToString(),
|
||||
Channel = "ApiOutbound",
|
||||
Target = "ERP.GetOrder",
|
||||
SourceSite = "site-1",
|
||||
Status = "Submitted",
|
||||
RetryCount = 0,
|
||||
CreatedAtUtc = now,
|
||||
UpdatedAtUtc = now,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestCachedTelemetry_RoutesToActor_ReturnsReply()
|
||||
{
|
||||
// Arrange — stub actor that echoes every received EventId back.
|
||||
var stubActor = Sys.ActorOf(Props.Create(() => new EchoCachedIngestActor()));
|
||||
|
||||
var server = CreateServer();
|
||||
server.SetAuditIngestActor(stubActor);
|
||||
|
||||
var packets = Enumerable.Range(0, 3)
|
||||
.Select(_ => NewPacket())
|
||||
.ToList();
|
||||
|
||||
var batch = new CachedTelemetryBatch();
|
||||
batch.Packets.AddRange(packets);
|
||||
|
||||
// Act
|
||||
var ack = await server.IngestCachedTelemetry(batch, NewContext());
|
||||
|
||||
// Assert — every packet's EventId appears in the ack, demonstrating
|
||||
// end-to-end routing through the actor.
|
||||
Assert.Equal(3, ack.AcceptedEventIds.Count);
|
||||
var expectedIds = packets.Select(p => p.AuditEvent.EventId).ToHashSet();
|
||||
Assert.True(expectedIds.SetEquals(ack.AcceptedEventIds.ToHashSet()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestCachedTelemetry_NoActorWired_ReturnsEmptyAck()
|
||||
{
|
||||
var server = CreateServer();
|
||||
// Intentionally do NOT call SetAuditIngestActor — simulates host
|
||||
// startup race window.
|
||||
|
||||
var batch = new CachedTelemetryBatch();
|
||||
batch.Packets.Add(NewPacket());
|
||||
|
||||
var ack = await server.IngestCachedTelemetry(batch, NewContext());
|
||||
|
||||
Assert.Empty(ack.AcceptedEventIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tiny ReceiveActor that echoes every EventId in an incoming
|
||||
/// <see cref="IngestCachedTelemetryCommand"/> back as an
|
||||
/// <see cref="IngestCachedTelemetryReply"/>. Stands in for the central
|
||||
/// AuditLogIngestActor so this test never touches MSSQL.
|
||||
/// </summary>
|
||||
private sealed class EchoCachedIngestActor : ReceiveActor
|
||||
{
|
||||
public EchoCachedIngestActor()
|
||||
{
|
||||
Receive<IngestCachedTelemetryCommand>(cmd =>
|
||||
{
|
||||
var ids = cmd.Entries.Select(e => e.Audit.EventId).ToList();
|
||||
Sender.Tell(new IngestCachedTelemetryReply(ids));
|
||||
});
|
||||
}
|
||||
var server = new SiteStreamGrpcServer(Substitute.For<ISiteStreamSubscriber>(),
|
||||
NullLogger<SiteStreamGrpcServer>.Instance);
|
||||
var request = new CachedTelemetryBatch();
|
||||
if (nonEmpty) request.Packets.Add(new CachedTelemetryPacket());
|
||||
var error = await Assert.ThrowsAsync<RpcException>(() => server.IngestCachedTelemetry(
|
||||
request, Substitute.For<ServerCallContext>()));
|
||||
Assert.Equal(StatusCode.Unimplemented, error.StatusCode);
|
||||
Assert.Contains("CentralControlService", error.Status.Detail);
|
||||
}
|
||||
}
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories;
|
||||
|
||||
public class AuditPurgeBudgetTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task BackloggedChannel_StopsAtBudgetIncludingPartialFinalBatch()
|
||||
{
|
||||
using var connection = new BackloggedConnection();
|
||||
await using var context = new ScadaBridgeDbContext(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlServer(connection).Options);
|
||||
var deleted = await new AuditLogRepository(context).PurgeChannelOlderThanAsync(
|
||||
"ScriptTrace", DateTime.UtcNow, batchSize: 3, maxRows: 5);
|
||||
Assert.Equal(5, deleted);
|
||||
Assert.Equal(new[] { 3, 2 }, connection.RequestedBatches);
|
||||
Assert.Equal(ConnectionState.Closed, connection.State);
|
||||
}
|
||||
|
||||
private sealed class BackloggedConnection : DbConnection
|
||||
{
|
||||
public List<int> RequestedBatches { get; } = new();
|
||||
private ConnectionState _state;
|
||||
[AllowNull] public override string ConnectionString { get; set; } = "Server=localhost;Database=test";
|
||||
public override string Database => "test";
|
||||
public override string DataSource => "localhost";
|
||||
public override string ServerVersion => "16.0";
|
||||
public override ConnectionState State => _state;
|
||||
public override void Open() => _state = ConnectionState.Open;
|
||||
public override void Close() => _state = ConnectionState.Closed;
|
||||
public override void ChangeDatabase(string databaseName) => throw new NotSupportedException();
|
||||
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => throw new NotSupportedException();
|
||||
protected override DbCommand CreateDbCommand() => new BackloggedCommand(this);
|
||||
}
|
||||
|
||||
private sealed class BackloggedCommand(BackloggedConnection owner) : DbCommand
|
||||
{
|
||||
private readonly SqlCommand _parameters = new();
|
||||
[AllowNull] public override string CommandText { get; set; } = "";
|
||||
public override int CommandTimeout { get; set; }
|
||||
public override CommandType CommandType { get; set; }
|
||||
public override bool DesignTimeVisible { get; set; }
|
||||
public override UpdateRowSource UpdatedRowSource { get; set; }
|
||||
protected override DbConnection? DbConnection { get; set; }
|
||||
protected override DbTransaction? DbTransaction { get; set; }
|
||||
protected override DbParameterCollection DbParameterCollection => _parameters.Parameters;
|
||||
protected override DbParameter CreateDbParameter() => new SqlParameter();
|
||||
public override int ExecuteNonQuery()
|
||||
{
|
||||
// A server with more eligible rows than any tick is allowed to delete.
|
||||
// Fail rapidly if the repository loops indefinitely, without a timer-based test.
|
||||
if (owner.RequestedBatches.Count >= 10)
|
||||
throw new InvalidOperationException("Purge did not yield despite exhausting its row budget.");
|
||||
var requested = (int)_parameters.Parameters["@batch"].Value;
|
||||
owner.RequestedBatches.Add(requested);
|
||||
return requested;
|
||||
}
|
||||
public override object? ExecuteScalar() => throw new NotSupportedException();
|
||||
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => throw new NotSupportedException();
|
||||
public override void Cancel() { }
|
||||
public override void Prepare() { }
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing) _parameters.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -29,6 +29,30 @@ public class TemplateEngineRepositoryTests : IDisposable
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TopologyPage_IsBoundedFilteredAndUntracked()
|
||||
{
|
||||
var site = new ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site("PageSite", "page-site");
|
||||
var other = new ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site("OtherPageSite", "other-page-site");
|
||||
var template = new Template("PageTemplate");
|
||||
_context.Sites.AddRange(site, other);
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.Instances.AddRange(Enumerable.Range(0, 101).Select(id =>
|
||||
new ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance($"Paged-{id:D3}") { SiteId = site.Id, TemplateId = template.Id }));
|
||||
_context.Instances.Add(new ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance("Other") { SiteId = other.Id, TemplateId = template.Id });
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
var first = await _repository.GetTopologyInstancesAsync(site.Id, null, 0, 50);
|
||||
var second = await _repository.GetTopologyInstancesAsync(site.Id, null, 50, 50);
|
||||
Assert.Equal(50, first.Count);
|
||||
Assert.Equal(50, second.Count);
|
||||
Assert.All(first.Concat(second), i => Assert.Equal(site.Id, i.SiteId));
|
||||
Assert.Empty(first.Select(i => i.Id).Intersect(second.Select(i => i.Id)));
|
||||
Assert.Empty(_context.ChangeTracker.Entries());
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => _repository.GetTopologyInstancesAsync(site.Id, null, 0, 51));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTemplateWithChildrenAsync_ReturnsTemplateWithAllMemberCollectionsPopulated()
|
||||
{
|
||||
|
||||
+2
-8
@@ -10,14 +10,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<!--
|
||||
Bundle C migration integration tests need Microsoft.Data.SqlClient. EF
|
||||
SqlServer 10.0.7 pulls in SqlClient >= 6.1.1, but the central package
|
||||
version is pinned at 6.0.2 (the version the production
|
||||
ExternalSystemGateway uses). Override the version locally for the test
|
||||
project only; production assemblies are unaffected.
|
||||
-->
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" VersionOverride="6.1.1" />
|
||||
<!-- Use the central SqlClient pin, matching the production EF dependency floor. -->
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||
<!-- Security pin: GHSA-2m69-gcr7-jv3q. Microsoft.Data.Sqlite pulls the
|
||||
vulnerable native 2.1.11; 2.1.12 patches it. Bumping Sqlite does not help. -->
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
|
||||
|
||||
public class GlobalOptionsValidationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("ScadaBridge:OpcUa:ApplicationName")]
|
||||
[InlineData("ScadaBridge:MxGateway:ClientNamePrefix")]
|
||||
public void StartupRejectsBlankIdentity(string key)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(
|
||||
new Dictionary<string, string?> { [key] = " " }).Build());
|
||||
services.AddDataConnectionLayer();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var exception = Assert.Throws<OptionsValidationException>(() => provider.GetRequiredService<IStartupValidator>().Validate());
|
||||
Assert.Contains(key, exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultsValidateAtStartup()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
|
||||
services.AddDataConnectionLayer();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
provider.GetRequiredService<IStartupValidator>().Validate();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,19 @@ namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests;
|
||||
|
||||
public class ConfigLoaderTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("relative")]
|
||||
[InlineData("ftp://example.com")]
|
||||
[InlineData("https://good.example,broken")]
|
||||
[InlineData("http://")]
|
||||
public void Load_RejectsInvalidEndpointsBeforeDispatch(string urls)
|
||||
{
|
||||
var error = Assert.Throws<InvalidOperationException>(() =>
|
||||
ConfigLoader.Load(System.Text.Json.JsonSerializer.Serialize(new { ScadaBridge = new { BaseUrls = urls } })));
|
||||
Assert.Contains("ScadaBridge:BaseUrls", error.Message);
|
||||
Assert.Contains("HTTP", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SplitBaseUrls_trims_and_drops_empties()
|
||||
{
|
||||
@@ -29,8 +42,8 @@ public class ConfigLoaderTests
|
||||
[Fact]
|
||||
public void Load_reads_all_fields()
|
||||
{
|
||||
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"BaseUrls\":\"a,b\",\"TimeoutSeconds\":5,\"LogPath\":\"l.log\"}}");
|
||||
Assert.Equal("a,b", cfg.ScadaBridge.BaseUrls);
|
||||
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"BaseUrls\":\"https://a,http://b\",\"TimeoutSeconds\":5,\"LogPath\":\"l.log\"}}");
|
||||
Assert.Equal("https://a,http://b", cfg.ScadaBridge.BaseUrls);
|
||||
Assert.Equal(5, cfg.ScadaBridge.TimeoutSeconds);
|
||||
Assert.Equal("l.log", cfg.ScadaBridge.LogPath);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
|
||||
using ZB.MOM.WW.ScadaBridge.Host;
|
||||
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
public class BootstrapCancellationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CancelledBootstrap_DoesNotResolveActorSystemOrJoin()
|
||||
{
|
||||
var accessed = 0;
|
||||
var coordinator = new ClusterBootstrapCoordinator(
|
||||
() => { accessed++; throw new InvalidOperationException("must not resolve during shutdown"); },
|
||||
Options.Create(new ClusterOptions { SeedNodes = ["akka.tcp://scadabridge@127.0.0.1:2551"] }),
|
||||
Options.Create(new NodeOptions { NodeHostname = "127.0.0.1", RemotingPort = 2551 }), NullLogger<ClusterBootstrapCoordinator>.Instance);
|
||||
var run = typeof(ClusterBootstrapCoordinator).GetMethod("RunAsync", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
await (Task)run.Invoke(coordinator, [new CancellationToken(true)])!;
|
||||
Assert.Equal(0, accessed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Reflection;
|
||||
using Grpc.AspNetCore.Server;
|
||||
using Grpc.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.LocalDb.Replication;
|
||||
using ZB.MOM.WW.Secrets.Replicator.Grpc;
|
||||
using ZB.MOM.WW.Secrets.Replicator.Grpc.DependencyInjection;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>Every actually mapped generated gRPC method must have an authentication gate.</summary>
|
||||
public class MappedGrpcAuthenticationCoverageTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EveryMappedGrpcService_IsCoveredIncludingExternalMappings()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Secrets:Replication:Enabled"] = "true",
|
||||
["Secrets:Replication:Mode"] = "Grpc",
|
||||
["Secrets:GrpcHub:BearerToken"] = "coverage-test-token",
|
||||
});
|
||||
builder.Services.AddGrpc();
|
||||
builder.Services.AddZbSecretsGrpcHub(builder.Configuration, "Secrets:GrpcHub");
|
||||
await using var app = builder.Build();
|
||||
|
||||
// Discover the production map calls, not a hand-maintained three-service type list.
|
||||
var root = FindRepositoryRoot();
|
||||
var hostSources = Directory.EnumerateFiles(Path.Combine(root, "src", "ZB.MOM.WW.ScadaBridge.Host"), "*.cs", SearchOption.AllDirectories)
|
||||
.Where(p => !p.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")
|
||||
&& !p.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}"))
|
||||
.Select(p => CSharpSyntaxTree.ParseText(File.ReadAllText(p)).GetRoot()).ToList();
|
||||
var typeNames = hostSources.SelectMany(s => s.DescendantNodes().OfType<GenericNameSyntax>())
|
||||
.Where(n => n.Identifier.ValueText == "MapGrpcService")
|
||||
.Select(n => n.TypeArgumentList.Arguments.Single().ToString()).Distinct().ToList();
|
||||
Assert.NotEmpty(typeNames);
|
||||
var assemblies = typeof(SecretsRegistration).Assembly.GetReferencedAssemblies().Select(Assembly.Load)
|
||||
.Append(typeof(SecretsRegistration).Assembly).ToList();
|
||||
var map = typeof(GrpcEndpointRouteBuilderExtensions).GetMethods()
|
||||
.Single(m => m.Name == "MapGrpcService" && m.IsGenericMethodDefinition);
|
||||
foreach (var name in typeNames)
|
||||
{
|
||||
var type = assemblies.Select(a => a.GetType(name)).Single(t => t is not null)!;
|
||||
map.MakeGenericMethod(type).Invoke(null, [app]);
|
||||
}
|
||||
|
||||
// Exercise the production extension mappings too; these packages hide their MapGrpcService calls.
|
||||
Assert.Contains(hostSources.SelectMany(s => s.DescendantNodes().OfType<InvocationExpressionSyntax>()),
|
||||
n => n.Expression.ToString().EndsWith(".MapZbLocalDbSync", StringComparison.Ordinal));
|
||||
app.MapZbLocalDbSync();
|
||||
app.MapScadaBridgeSecretsHub(builder.Configuration);
|
||||
var metadata = ((IEndpointRouteBuilder)app).DataSources.SelectMany(s => s.Endpoints)
|
||||
.Select(e => e.Metadata.GetMetadata<GrpcMethodMetadata>()).Where(m => m is not null).ToList();
|
||||
Assert.NotEmpty(metadata);
|
||||
var serviceTypes = metadata.Select(m => m!.ServiceType).Distinct().ToList();
|
||||
Assert.True(serviceTypes.Count >= typeNames.Count + 2, "External LocalDb and Secrets mappings must both be exercised.");
|
||||
|
||||
var prefixes = ControlPlaneAuthInterceptor.DefaultGatedPrefixes
|
||||
.Concat(CentralControlAuthInterceptor.DefaultGatedPrefixes)
|
||||
.Append((string)typeof(LocalDbSyncAuthInterceptor).GetField("ServicePrefix", BindingFlags.Static | BindingFlags.NonPublic)!.GetRawConstantValue()!)
|
||||
.ToArray();
|
||||
foreach (var type in serviceTypes)
|
||||
{
|
||||
var binder = new MethodCollector();
|
||||
var attribute = type.GetCustomAttribute<BindServiceMethodAttribute>(inherit: true);
|
||||
Assert.NotNull(attribute);
|
||||
var bind = attribute.BindType.GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||
.Single(m => m.Name == attribute.BindMethodName && m.GetParameters().Length == 2
|
||||
&& m.GetParameters()[0].ParameterType == typeof(ServiceBinderBase));
|
||||
bind.Invoke(null, [binder, null]);
|
||||
Assert.NotEmpty(binder.Methods);
|
||||
Assert.Equal(metadata.Where(m => m!.ServiceType == type).Select(m => m!.Method.FullName).Order(), binder.Methods.Order());
|
||||
|
||||
// Secrets installs an all-method interceptor on its own service options. Inspect actual DI options.
|
||||
var optionsType = typeof(IOptions<>).MakeGenericType(typeof(GrpcServiceOptions<>).MakeGenericType(type));
|
||||
var options = app.Services.GetRequiredService(optionsType);
|
||||
var serviceOptions = (GrpcServiceOptions)optionsType.GetProperty("Value")!.GetValue(options)!;
|
||||
var hasSecretsGate = serviceOptions.Interceptors.Any(i => i.Type == typeof(SecretsHubAuthInterceptor));
|
||||
Assert.All(binder.Methods, method => Assert.True(
|
||||
hasSecretsGate || prefixes.Any(prefix => method.StartsWith(prefix, StringComparison.Ordinal)),
|
||||
$"Mapped service {type.FullName} method {method} has no authentication interceptor gate."));
|
||||
if (!hasSecretsGate)
|
||||
{
|
||||
// Negative control uses the real service's methods: removing its gate must be detected.
|
||||
var removed = prefixes.First(p => binder.Methods.Any(m => m.StartsWith(p, StringComparison.Ordinal)));
|
||||
var remaining = prefixes.Where(p => p != removed).ToArray();
|
||||
Assert.Contains(binder.Methods, method => !remaining.Any(p => method.StartsWith(p, StringComparison.Ordinal)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent)
|
||||
if (File.Exists(Path.Combine(directory.FullName, "ZB.MOM.WW.ScadaBridge.slnx"))) return directory.FullName;
|
||||
throw new InvalidOperationException("Repository root not found.");
|
||||
}
|
||||
|
||||
private sealed class MethodCollector : ServiceBinderBase
|
||||
{
|
||||
public List<string> Methods { get; } = [];
|
||||
public override void AddMethod<TRequest, TResponse>(Method<TRequest, TResponse> method, UnaryServerMethod<TRequest, TResponse>? handler)
|
||||
=> Methods.Add(method.FullName);
|
||||
public override void AddMethod<TRequest, TResponse>(Method<TRequest, TResponse> method, ClientStreamingServerMethod<TRequest, TResponse>? handler)
|
||||
=> Methods.Add(method.FullName);
|
||||
public override void AddMethod<TRequest, TResponse>(Method<TRequest, TResponse> method, ServerStreamingServerMethod<TRequest, TResponse>? handler)
|
||||
=> Methods.Add(method.FullName);
|
||||
public override void AddMethod<TRequest, TResponse>(Method<TRequest, TResponse> method, DuplexStreamingServerMethod<TRequest, TResponse>? handler)
|
||||
=> Methods.Add(method.FullName);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,20 @@ public class StandbyRequestForwardingTests
|
||||
// Filter: when does a standby relay, and when does it refuse?
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Relay_DoesNotNegotiateAnEncodingItCannotForward()
|
||||
{
|
||||
var handler = new FakeHandler(request =>
|
||||
{
|
||||
Assert.False(request.Headers.Contains("Accept-Encoding"));
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("plain") });
|
||||
});
|
||||
var context = BuildRequestContext("{}");
|
||||
context.Request.Headers.AcceptEncoding = "gzip, br";
|
||||
await CreateForwarder(handler, "http://a").ForwardAsync(context, CancellationToken.None);
|
||||
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
|
||||
@@ -112,7 +112,7 @@ public class SiteAuditPushFlowTests : TestKit
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default)
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
@@ -26,11 +27,7 @@ public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory>
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
var content = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("username", ""),
|
||||
new KeyValuePair<string, string>("password", "")
|
||||
});
|
||||
using var content = await CreateLoginFormAsync(client, "", "");
|
||||
|
||||
var response = await client.PostAsync("/auth/login", content);
|
||||
|
||||
@@ -121,11 +118,7 @@ public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory>
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
var content = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("username", "admin"),
|
||||
new KeyValuePair<string, string>("password", "password")
|
||||
});
|
||||
using var content = await CreateLoginFormAsync(client, "admin", "password");
|
||||
|
||||
var response = await client.PostAsync("/auth/login", content);
|
||||
|
||||
@@ -139,6 +132,42 @@ public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory>
|
||||
Assert.Contains("ZB.MOM.WW.ScadaBridge.Auth", setCookieHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoginEndpoint_WithoutAntiforgeryToken_RejectsWithoutAuthCookie()
|
||||
{
|
||||
using var client = _factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
using var content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["username"] = "admin", ["password"] = "password"
|
||||
});
|
||||
using var response = await client.PostAsync("/auth/login", content);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
if (response.Headers.TryGetValues("Set-Cookie", out var cookies))
|
||||
Assert.DoesNotContain(cookies, cookie => cookie.StartsWith(SecurityOptions.DefaultCookieName + "=", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static async Task<FormUrlEncodedContent> CreateLoginFormAsync(HttpClient client, string username, string password)
|
||||
{
|
||||
// Fetch the actual rendered form. WebApplicationFactory's cookie handler retains
|
||||
// its antiforgery cookie, so the POST exercises the same pair as a browser login.
|
||||
using var page = await client.GetAsync("/login");
|
||||
Assert.Equal(HttpStatusCode.OK, page.StatusCode);
|
||||
var html = await page.Content.ReadAsStringAsync();
|
||||
var input = Regex.Match(html, """<input\b[^>]*name="__RequestVerificationToken"[^>]*>""", RegexOptions.IgnoreCase);
|
||||
Assert.True(input.Success, "The login form must render an antiforgery input.");
|
||||
var value = Regex.Match(input.Value, "\\bvalue=\"([^\"]+)\"", RegexOptions.IgnoreCase);
|
||||
Assert.True(value.Success, "The login form must provide a nonempty antiforgery token.");
|
||||
return new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["username"] = username,
|
||||
["password"] = password,
|
||||
["__RequestVerificationToken"] = WebUtility.HtmlDecode(value.Groups[1].Value)
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<bool> IsLdapAvailableAsync()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -11,6 +11,14 @@ public class KpiHistoryOptionsValidatorTests
|
||||
{
|
||||
private static KpiHistoryOptionsValidator NewValidator() => new();
|
||||
|
||||
[Fact]
|
||||
public void Validate_SourceTimeout_Zero_Fails()
|
||||
{
|
||||
var result = NewValidator().Validate(null, new KpiHistoryOptions { SourceTimeout = TimeSpan.Zero });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("SourceTimeout", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Defaults_Pass()
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
|
||||
@@ -470,6 +471,7 @@ public class KpiHistoryRecorderActorTests : TestKit
|
||||
IKpiHistoryRepository repository, params IKpiSampleSource[] sources)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddKpiHistory(new ConfigurationBuilder().Build());
|
||||
// Mirror production: sources + repository are scoped, so the recorder opens a fresh
|
||||
// scope per tick and resolves there.
|
||||
foreach (var source in sources)
|
||||
@@ -519,6 +521,80 @@ public class KpiHistoryRecorderActorTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class HangingState
|
||||
{
|
||||
public readonly TaskCompletionSource<IReadOnlyList<KpiSample>> Completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public int Calls;
|
||||
public int ActiveScopesDisposed;
|
||||
}
|
||||
|
||||
private sealed class HangingSource(HangingState state) : IKpiSampleSource, IDisposable
|
||||
{
|
||||
private bool _called;
|
||||
public string Source => "hung";
|
||||
public Task<IReadOnlyList<KpiSample>> CollectAsync(DateTime capturedAtUtc, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_called = true;
|
||||
Interlocked.Increment(ref state.Calls);
|
||||
return state.Completion.Task; // deliberately ignores cancellation
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
if (_called) Interlocked.Increment(ref state.ActiveScopesDisposed);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void HungSource_DoesNotBlockHealthySourcesOrLoseScopeOwnership(bool replaceActor)
|
||||
{
|
||||
var state = new HangingState();
|
||||
var repository = new RecordingRepository();
|
||||
var services = new ServiceCollection();
|
||||
services.AddKpiHistory(new ConfigurationBuilder().Build());
|
||||
services.AddScoped<IKpiSampleSource>(_ => new HangingSource(state));
|
||||
services.AddScoped<IKpiSampleSource>(_ => new HealthySource());
|
||||
services.AddScoped<IKpiHistoryRepository>(_ => repository);
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var options = new KpiHistoryOptions
|
||||
{
|
||||
SourceTimeout = TimeSpan.FromMilliseconds(50),
|
||||
SampleInterval = TimeSpan.FromHours(1),
|
||||
};
|
||||
var actor = CreateActor(provider, options);
|
||||
try
|
||||
{
|
||||
actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
|
||||
AwaitAssert(() => Assert.Equal(2, repository.Recorded.Count), TimeSpan.FromSeconds(2));
|
||||
if (replaceActor)
|
||||
{
|
||||
Watch(actor);
|
||||
Sys.Stop(actor);
|
||||
ExpectTerminated(actor);
|
||||
Assert.Equal(0, Volatile.Read(ref state.ActiveScopesDisposed));
|
||||
actor = CreateActor(provider, options);
|
||||
}
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
|
||||
Assert.True(repository.Recorded.Count >= 4);
|
||||
}, TimeSpan.FromSeconds(2));
|
||||
Assert.Equal(1, Volatile.Read(ref state.Calls));
|
||||
Assert.Equal(0, Volatile.Read(ref state.ActiveScopesDisposed));
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Stop before releasing the source so no queued sampling tick can legitimately
|
||||
// begin a replacement after it finishes and race the disposal assertion.
|
||||
Watch(actor);
|
||||
Sys.Stop(actor);
|
||||
ExpectTerminated(actor);
|
||||
state.Completion.TrySetResult(Array.Empty<KpiSample>());
|
||||
}
|
||||
AwaitAssert(() => Assert.Equal(1, Volatile.Read(ref state.ActiveScopesDisposed)), TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SampleTick_WritesHealthySourceSamples_AndThrowingSourceDoesNotAbortTick()
|
||||
{
|
||||
|
||||
@@ -60,6 +60,166 @@ public class FlowDebugRunnerTests
|
||||
|
||||
private static JsonElement Json(string s) => JsonDocument.Parse(s).RootElement.Clone();
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-3600)]
|
||||
public async Task RunValueChange_FindsLateCompletionBehindLogCursorDespiteSiteClockSkew(int skewSeconds)
|
||||
{
|
||||
var (_, _, puller) = WireValueChange();
|
||||
var child = Guid.NewGuid();
|
||||
var pollStarted = _clock;
|
||||
var siteStarted = DateTime.UtcNow.AddSeconds(skewSeconds);
|
||||
Guid root = default;
|
||||
_router.RouteToSetAttributesAsync("site-a", Arg.Any<RouteToSetAttributesRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
root = ci.Arg<RouteToSetAttributesRequest>().ParentExecutionId!.Value;
|
||||
return new RouteToSetAttributesResponse("x", true, null, DateTimeOffset.UtcNow);
|
||||
});
|
||||
var logId = Guid.NewGuid();
|
||||
var runId = Guid.NewGuid();
|
||||
puller.PullAsync("site-a", Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
var rows = new List<AuditEvent>
|
||||
{
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptLog,
|
||||
AuditStatus.Delivered, eventId: logId, executionId: child, parentExecutionId: root,
|
||||
occurredAtUtc: siteStarted.AddSeconds(1)),
|
||||
};
|
||||
// Runtime stamps its closing ScriptRun row with the START time, after live log rows were already polled.
|
||||
if (_clock - pollStarted >= TimeSpan.FromSeconds(2))
|
||||
rows.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun,
|
||||
AuditStatus.Delivered, eventId: runId, executionId: child, parentExecutionId: root,
|
||||
occurredAtUtc: siteStarted));
|
||||
var since = ci.Arg<DateTime>();
|
||||
var after = ci.ArgAt<string?>(5);
|
||||
var ids = ci.Arg<IReadOnlyCollection<Guid>>();
|
||||
return (IReadOnlyList<AuditEvent>)rows.Where(e => (ids.Contains(root) || ids.Contains(child))
|
||||
&& (e.OccurredAtUtc.UtcDateTime > since || e.OccurredAtUtc.UtcDateTime == since
|
||||
&& (after is null || string.CompareOrdinal(e.EventId.ToString("D"), after) > 0)))
|
||||
.OrderBy(e => e.OccurredAtUtc).ThenBy(e => e.EventId.ToString("D"), StringComparer.Ordinal).ToArray();
|
||||
});
|
||||
var result = await Runner().RunValueChangeAsync(_services.BuildServiceProvider(),
|
||||
new FlowDebugRunValueChangeCommand(5, "Status", "Running", WaitSeconds: 5), Deployer, CancellationToken.None);
|
||||
Assert.True(result.Completed);
|
||||
Assert.Contains(result.Events, e => e.EventId == runId);
|
||||
Assert.Contains(result.Events, e => e.EventId == logId);
|
||||
Assert.Equal(3, result.Events.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunValueChange_PagesTiesAndQueriesOlderDescendantsBeyond64Ids()
|
||||
{
|
||||
var (_, _, puller) = WireValueChange();
|
||||
var children = Enumerable.Range(0, 70).Select(_ => Guid.NewGuid()).ToArray();
|
||||
var grandchildren = children.ToDictionary(id => id, _ => Guid.NewGuid());
|
||||
var rows = new List<AuditEvent>();
|
||||
Guid root = default;
|
||||
_router.RouteToSetAttributesAsync("site-a", Arg.Any<RouteToSetAttributesRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
root = ci.Arg<RouteToSetAttributesRequest>().ParentExecutionId!.Value;
|
||||
var t = DateTime.UtcNow;
|
||||
rows.AddRange(Enumerable.Range(1, 2001).Select(n => ScadaBridgeAuditEventFactory.Create(
|
||||
AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered,
|
||||
eventId: Guid.Parse($"00000000-0000-0000-0000-{n:D12}"), occurredAtUtc: t, executionId: root)));
|
||||
rows.AddRange(children.Select((id, n) => ScadaBridgeAuditEventFactory.Create(
|
||||
AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
|
||||
eventId: Guid.Parse($"ffffffff-0000-0000-0000-{n:D12}"), occurredAtUtc: t,
|
||||
executionId: id, parentExecutionId: root)));
|
||||
// These are older than the root's page cursor and only visible once child IDs are queried.
|
||||
rows.AddRange(children.Select(id => ScadaBridgeAuditEventFactory.Create(
|
||||
AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
|
||||
occurredAtUtc: t.AddSeconds(-1), executionId: grandchildren[id], parentExecutionId: id)));
|
||||
return new RouteToSetAttributesResponse("x", true, null, DateTimeOffset.UtcNow);
|
||||
});
|
||||
var queried = new HashSet<Guid>();
|
||||
puller.PullAsync("site-a", Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
var ids = ci.Arg<IReadOnlyCollection<Guid>>();
|
||||
Assert.InRange(ids.Count, 1, 64);
|
||||
queried.UnionWith(ids);
|
||||
var since = ci.Arg<DateTime>();
|
||||
var after = ci.ArgAt<string?>(5);
|
||||
return (IReadOnlyList<AuditEvent>)rows.Where(e =>
|
||||
{
|
||||
var d = AuditDetailsCodec.Deserialize(e.DetailsJson);
|
||||
return (ids.Contains(d.ExecutionId ?? Guid.Empty) || ids.Contains(d.ParentExecutionId ?? Guid.Empty))
|
||||
&& (e.OccurredAtUtc.UtcDateTime > since || e.OccurredAtUtc.UtcDateTime == since
|
||||
&& (after is null || string.CompareOrdinal(e.EventId.ToString("D"), after) > 0));
|
||||
}).OrderBy(e => e.OccurredAtUtc).ThenBy(e => e.EventId.ToString("D"), StringComparer.Ordinal).Take(ci.Arg<int>()).ToArray();
|
||||
});
|
||||
var result = await Runner().RunValueChangeAsync(_services.BuildServiceProvider(),
|
||||
new FlowDebugRunValueChangeCommand(5, "Status", "Running", WaitSeconds: 4), Deployer, CancellationToken.None);
|
||||
Assert.True(result.Completed);
|
||||
Assert.True(result.LogsTruncated);
|
||||
Assert.All(children, id => Assert.Contains(id, queried));
|
||||
Assert.All(grandchildren.Values, id => Assert.Contains(id, queried));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Replay_UsesChainAnchorEvenWhenChildClockPrecedesRoot()
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
var root = Guid.NewGuid();
|
||||
var child = Guid.NewGuid();
|
||||
var childRow = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun,
|
||||
AuditStatus.Delivered, executionId: child, parentExecutionId: root);
|
||||
repo.GetTraceEventsAsync(child, Arg.Any<CancellationToken>()).Returns(new TraceEventBatch(root, [childRow], false));
|
||||
_services.AddSingleton(repo);
|
||||
var result = await Runner().ReplayAsync(_services.BuildServiceProvider(), new FlowDebugReplayCommand(child), CancellationToken.None, Deployer);
|
||||
Assert.Equal(root, result.RootExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunValueChange_TotalDeadlineIncludesInitialWrite()
|
||||
{
|
||||
var (_, _, puller) = WireValueChange();
|
||||
_services.Configure<ManagementServiceOptions>(o => o.LongRunningCommandTimeout = TimeSpan.FromSeconds(6));
|
||||
_router.RouteToSetAttributesAsync("site-a", Arg.Any<RouteToSetAttributesRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(async ci =>
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, ci.Arg<CancellationToken>());
|
||||
return new RouteToSetAttributesResponse("x", true, null, DateTimeOffset.UtcNow);
|
||||
});
|
||||
var task = Runner().RunValueChangeAsync(_services.BuildServiceProvider(),
|
||||
new FlowDebugRunValueChangeCommand(5, "Status", "Running", WaitSeconds: 300), Deployer, CancellationToken.None);
|
||||
var ex = await Assert.ThrowsAsync<ManagementCommandException>(() => task.WaitAsync(TimeSpan.FromSeconds(3)));
|
||||
Assert.Equal(FlowDebugRunner.TimeoutCode, ex.Code);
|
||||
Assert.Empty(puller.ReceivedCalls());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunValueChange_TotalDeadlineIncludesAuditWrite()
|
||||
{
|
||||
var (_, _, puller) = WireValueChange();
|
||||
_services.Configure<ManagementServiceOptions>(o => o.LongRunningCommandTimeout = TimeSpan.FromSeconds(6));
|
||||
var writer = Substitute.For<ICentralAuditWriter>();
|
||||
writer.WriteAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => Task.Delay(Timeout.InfiniteTimeSpan, ci.Arg<CancellationToken>()));
|
||||
_services.AddSingleton(writer);
|
||||
var ex = await Assert.ThrowsAsync<ManagementCommandException>(() => Runner().RunValueChangeAsync(_services.BuildServiceProvider(),
|
||||
new FlowDebugRunValueChangeCommand(5, "Status", "Running", WaitSeconds: 300), Deployer, CancellationToken.None)
|
||||
.WaitAsync(TimeSpan.FromSeconds(3)));
|
||||
Assert.Equal(FlowDebugRunner.TimeoutCode, ex.Code);
|
||||
Assert.Empty(puller.ReceivedCalls());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunValueChange_CapsReturnedEventsAndReportsTruncation()
|
||||
{
|
||||
var (_, _, puller) = WireValueChange();
|
||||
var events = Enumerable.Range(0, 700).Select(_ => ScadaBridgeAuditEventFactory.Create(
|
||||
AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered)).ToArray();
|
||||
puller.PullAsync(default!, default!, default, default, default, default).ReturnsForAnyArgs(events);
|
||||
var result = await Runner().RunValueChangeAsync(_services.BuildServiceProvider(),
|
||||
new FlowDebugRunValueChangeCommand(5, "Status", "Running", WaitSeconds: 1), Deployer, CancellationToken.None);
|
||||
Assert.True(result.Truncated);
|
||||
Assert.InRange(result.Events.Count, 1, FlowTraceCollector.DefaultMaxEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunMethod_OnStandby_RefusesWithStandbyCode_BeforeTouchingTheMethod()
|
||||
{
|
||||
@@ -207,7 +367,7 @@ public class FlowDebugRunnerTests
|
||||
_router.RouteToSetAttributesAsync("site-a", Arg.Do<RouteToSetAttributesRequest>(r => write = r), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToSetAttributesResponse(((RouteToSetAttributesRequest)ci[1]).CorrelationId, true, null, DateTimeOffset.UtcNow));
|
||||
var calls = 0;
|
||||
puller.PullAsync("site-a", Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
puller.PullAsync("site-a", Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
calls++;
|
||||
@@ -240,7 +400,7 @@ public class FlowDebugRunnerTests
|
||||
public async Task RunValueChange_NoSpawnWithinWindow_ReportsNoTriggerCode()
|
||||
{
|
||||
var (_, _, puller) = WireValueChange();
|
||||
puller.PullAsync(default!, default!, default, default, default).ReturnsForAnyArgs(Array.Empty<AuditEvent>());
|
||||
puller.PullAsync(default!, default!, default, default, default, default).ReturnsForAnyArgs(Array.Empty<AuditEvent>());
|
||||
|
||||
var result = await Runner().RunValueChangeAsync(_services.BuildServiceProvider(),
|
||||
new FlowDebugRunValueChangeCommand(5, "Status", "Running", WaitSeconds: 3), Deployer, CancellationToken.None);
|
||||
@@ -260,7 +420,7 @@ public class FlowDebugRunnerTests
|
||||
// the site's own ceiling (SiteStreamGrpcServer.MaxTraceEventsPerPull) instead.
|
||||
var (_, _, puller) = WireValueChange();
|
||||
var maxima = new List<int>();
|
||||
puller.PullAsync("site-a", Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<DateTime>(), Arg.Do<int>(maxima.Add), Arg.Any<CancellationToken>())
|
||||
puller.PullAsync("site-a", Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<DateTime>(), Arg.Do<int>(maxima.Add), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns(Array.Empty<AuditEvent>());
|
||||
|
||||
await Runner().RunValueChangeAsync(_services.BuildServiceProvider(),
|
||||
@@ -476,7 +636,7 @@ public class FlowDebugRunnerTests
|
||||
public async Task RunValueChange_SynthesizedRootRow_IsRedacted_WhenARedactorIsRegistered()
|
||||
{
|
||||
var (_, _, puller) = WireValueChange();
|
||||
puller.PullAsync(default!, default!, default, default, default).ReturnsForAnyArgs(Array.Empty<AuditEvent>());
|
||||
puller.PullAsync(default!, default!, default, default, default, default).ReturnsForAnyArgs(Array.Empty<AuditEvent>());
|
||||
_services.AddSingleton<IAuditRedactor>(new UpperRedactor());
|
||||
|
||||
var result = await Runner().RunValueChangeAsync(_services.BuildServiceProvider(),
|
||||
@@ -587,7 +747,7 @@ public class FlowDebugRunnerTests
|
||||
// poll, then the SAME row (fixed event id) from the second on — a fresh id per poll would
|
||||
// re-arm the settle window forever and the loop would only ever end on the deadline.
|
||||
var calls = 0;
|
||||
puller.PullAsync("site-a", Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
puller.PullAsync("site-a", Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
calls++;
|
||||
@@ -662,7 +822,7 @@ public class FlowDebugRunnerTests
|
||||
// The write may fire several scripts. A sibling's run is still COLLECTED (the graph draws
|
||||
// it) but must not report the script the operator chose as having run.
|
||||
var (_, _, puller, _) = WireScript(WriteScript("OnLevel", "ValueChange", "Level"));
|
||||
puller.PullAsync("site-a", Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
puller.PullAsync("site-a", Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns(ci => (IReadOnlyList<AuditEvent>)[ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
|
||||
eventId: Guid.Parse("cccccccc-0000-0000-0000-000000000001"),
|
||||
executionId: Guid.NewGuid(), parentExecutionId: ((IReadOnlyCollection<Guid>)ci[1]).First(), target: "OtherScript")]);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
public class ManagementPagingBoundaryTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, 1100)]
|
||||
[InlineData(-1, 1100)]
|
||||
[InlineData(0, 1100)]
|
||||
[InlineData(1, 1)]
|
||||
[InlineData(1000, 1000)]
|
||||
[InlineData(1001, 1000)]
|
||||
[InlineData(int.MaxValue, 1000)]
|
||||
public void PositiveTakeIsCapped_OmittedOrNonpositiveTakePreservesUnlimited(int? take, int expected)
|
||||
{
|
||||
var page = typeof(ManagementActor).GetMethod("Page", BindingFlags.Static | BindingFlags.NonPublic)!.MakeGenericMethod(typeof(int));
|
||||
var rows = (List<int>)page.Invoke(null, [Enumerable.Range(0, 1100), -1, take])!;
|
||||
Assert.Equal(expected, rows.Count);
|
||||
Assert.Equal(0, rows[0]);
|
||||
}
|
||||
}
|
||||
+8
@@ -15,6 +15,14 @@ public class ManagementServiceOptionsValidatorTests
|
||||
private static ValidateOptionsResult Validate(ManagementServiceOptions options) =>
|
||||
new ManagementServiceOptionsValidator().Validate(Options.DefaultName, options);
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(5)]
|
||||
public void LongRunningTimeoutMustLeaveResponseGrace(int seconds)
|
||||
{
|
||||
Assert.True(Validate(new ManagementServiceOptions { LongRunningCommandTimeout = TimeSpan.FromSeconds(seconds) }).Failed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_AreValid()
|
||||
{
|
||||
|
||||
@@ -16,6 +16,14 @@ public class SiteCallAuditOptionsValidatorTests
|
||||
private static ValidateOptionsResult Validate(SiteCallAuditOptions options) =>
|
||||
new SiteCallAuditOptionsValidator().Validate(Options.DefaultName, options);
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, true)]
|
||||
[InlineData(2000, true)]
|
||||
[InlineData(2001, false)]
|
||||
[InlineData(int.MaxValue, false)]
|
||||
public void ReconciliationBatchSize_MatchesRpcCeiling(int size, bool valid)
|
||||
=> Assert.Equal(valid, Validate(new SiteCallAuditOptions { ReconciliationBatchSize = size }).Succeeded);
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_AreValid()
|
||||
{
|
||||
|
||||
+2
-7
@@ -11,13 +11,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka.TestKit.Xunit2" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<!--
|
||||
MSSQL-backed SiteCallAuditActor tests use the MsSqlMigrationFixture
|
||||
(reused from ConfigurationDatabase.Tests). Pinning 6.1.1 here mirrors
|
||||
AuditLog.Tests: EF SqlServer 10.0.7 needs >= 6.1.1 but the central pin
|
||||
is 6.0.2 (production ExternalSystemGateway). Override is test-only.
|
||||
-->
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" VersionOverride="6.1.1" />
|
||||
<!-- Use the central SqlClient pin, matching the production EF dependency floor. -->
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
|
||||
+6
-4
@@ -127,18 +127,20 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
|
||||
// Hop 3 — Alarm Actor → AlarmExecutionActor
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ScriptWrittenValue_FiringAlarm_ParentsOnTriggerRunToTheWritingExecution()
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void ScriptWrittenValue_FiringAlarm_ParentsOnlyObservedWrites(bool inferred)
|
||||
{
|
||||
var writerExecutionId = Guid.NewGuid();
|
||||
var alarm = BuildAlarm(ValueMatchAlarm(), out var instanceProbe);
|
||||
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow,
|
||||
SourceExecutionId: writerExecutionId));
|
||||
SourceExecutionId: writerExecutionId, SourceExecutionInferred: inferred));
|
||||
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(writerExecutionId, alarm.UnderlyingActor.LastOnTriggerParentExecutionId);
|
||||
Assert.Equal(inferred ? (Guid?)null : writerExecutionId, alarm.UnderlyingActor.LastOnTriggerParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -119,6 +119,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable
|
||||
|
||||
Assert.Equal(writer, published.SourceExecutionId);
|
||||
Assert.True(published.Trace);
|
||||
Assert.True(published.SourceExecutionInferred);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -147,14 +147,16 @@ public class ScriptActorTraceTests : TestKit, IDisposable
|
||||
Assert.Empty(_writer.Events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValueChangeTrigger_TracedChange_RunParentsToSourceExecutionId()
|
||||
[Theory]
|
||||
[InlineData(false, "value-change")]
|
||||
[InlineData(true, "inferred-device-update")]
|
||||
public void ValueChangeTrigger_TracedChange_RunParentsToSourceExecutionId(bool inferred, string triggerSource)
|
||||
{
|
||||
var actor = CreateActor("OnStatus", "1", "{\"attribute\":\"Status\"}");
|
||||
var writer = Guid.NewGuid();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
actor.Tell(new AttributeValueChanged("Pump1", "Status", "Status", "Running", "Good", now, SourceExecutionId: writer, Trace: true));
|
||||
actor.Tell(new AttributeValueChanged("Pump1", "Status", "Status", "Running", "Good", now, SourceExecutionId: writer, Trace: true, SourceExecutionInferred: inferred));
|
||||
|
||||
var evt = _writer.WaitForKind("ScriptTrace.ScriptRun", TimeSpan.FromSeconds(10));
|
||||
Assert.NotNull(evt);
|
||||
@@ -165,7 +167,7 @@ public class ScriptActorTraceTests : TestKit, IDisposable
|
||||
Assert.Equal("valuechange", extra.TriggerKind);
|
||||
Assert.Equal("Status", extra.TriggerAttribute);
|
||||
Assert.Equal("Running", extra.TriggerValue);
|
||||
Assert.Equal("value-change", extra.TriggerSource);
|
||||
Assert.Equal(triggerSource, extra.TriggerSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+14
@@ -10,6 +10,20 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
/// </summary>
|
||||
public class ScriptExecutionSchedulerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Scheduler_DoesNotInlineOnAnotherSchedulersWorker()
|
||||
{
|
||||
using var first = new ScriptExecutionScheduler(1);
|
||||
using var second = new ScriptExecutionScheduler(1);
|
||||
await Task.Factory.StartNew(() =>
|
||||
{
|
||||
var firstThread = Environment.CurrentManagedThreadId;
|
||||
var nested = new Task<int>(() => Environment.CurrentManagedThreadId);
|
||||
nested.RunSynchronously(second);
|
||||
Assert.NotEqual(firstThread, nested.Result);
|
||||
}, CancellationToken.None, TaskCreationOptions.None, first);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Scheduler_RunsWork_OffTheThreadPool()
|
||||
{
|
||||
|
||||
+22
-26
@@ -615,32 +615,31 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RuntimeWarning_InsideATracedWrite_IsAttributedToThatWriteStep()
|
||||
public async Task TraceWriteFailure_DoesNotCreateDiagnosticTraceRows()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var collector = new FlowTraceCollector();
|
||||
// The throwing writer makes EmitTrace log its best-effort "write failed" warning while the
|
||||
// AttributeWrite step scope is open — exactly the runtime line D6 exists to attribute.
|
||||
//
|
||||
// It also PINS THE ANTI-RECURSION PROPERTY: EmitLog's own write fails against this writer
|
||||
// too, so its "log-line write failed" warning must go to the UNDECORATED logger. Routed
|
||||
// through the capturing decorator instead, each warning would emit another row whose write
|
||||
// fails again — an unbounded loop, and this test would hang or blow the row count rather
|
||||
// than see exactly one ScriptLog row.
|
||||
var ctx = CreateContext(probe.Ref, new ThrowingAuditWriter(), traced: true, collector);
|
||||
|
||||
var call = ctx.SetAttribute("Setpoint", "42");
|
||||
var cmd = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
|
||||
probe.Reply(new SetStaticAttributeResponse(cmd.CorrelationId, InstanceName, "Setpoint", true, null, DateTimeOffset.UtcNow));
|
||||
await call;
|
||||
Assert.Single(collector.Snapshot());
|
||||
Assert.Equal(AuditKind.AttributeWrite, Row(collector.Snapshot().Single()).Kind);
|
||||
}
|
||||
|
||||
var rows = collector.Snapshot();
|
||||
var write = rows.Single(e => Row(e).Kind == AuditKind.AttributeWrite);
|
||||
var log = rows.Single(e => Row(e).Kind == AuditKind.ScriptLog);
|
||||
var extra = ScriptTraceExtra.TryParse(Row(log).Extra)!;
|
||||
Assert.Equal("runtime", extra.LogSource);
|
||||
Assert.Equal("warn", extra.LogLevel);
|
||||
Assert.Equal(write.EventId, extra.StepEventId);
|
||||
[Fact]
|
||||
public void Helpers_AreCachedPerContext()
|
||||
{
|
||||
var ctx = CreateContext(CreateTestProbe().Ref, null, traced: false, collector: null);
|
||||
Assert.Same(ctx.Scripts, ctx.Scripts);
|
||||
Assert.Same(ctx.ExternalSystem, ctx.ExternalSystem);
|
||||
Assert.Same(ctx.Database, ctx.Database);
|
||||
Assert.Same(ctx.Notify, ctx.Notify);
|
||||
Assert.Same(ctx.Alarms, ctx.Alarms);
|
||||
Assert.Same(ctx.Tracking, ctx.Tracking);
|
||||
var child = ctx.CreateChildContextForSharedScript(1);
|
||||
Assert.NotSame(ctx.Scripts, child.Scripts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -784,12 +783,11 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review item 4: step scopes NEST — the batch leg's lines name the batch row, the wait leg's
|
||||
/// name the wait row, and both unwind so a later line falls back to the run anchor. The
|
||||
/// throwing writer is again what provokes a runtime line inside each leg.
|
||||
/// The batch and wait scopes unwind even when trace writes fail; their internal
|
||||
/// diagnostics stay out of the collector, and the later script line belongs to the run.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task NestedStepScopes_AttributeEachLegSeparately_AndUnwind()
|
||||
public async Task NestedStepScopes_IgnoreTraceDiagnostics_AndUnwind()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var collector = new FlowTraceCollector();
|
||||
@@ -816,10 +814,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
|
||||
var logs = rows.Where(e => Row(e).Kind == AuditKind.ScriptLog)
|
||||
.Select(e => ScriptTraceExtra.TryParse(Row(e).Extra)!)
|
||||
.ToList();
|
||||
Assert.Equal(3, logs.Count);
|
||||
Assert.Equal(write.EventId, logs[0].StepEventId);
|
||||
Assert.Equal(wait.EventId, logs[1].StepEventId);
|
||||
Assert.Null(logs[2].StepEventId);
|
||||
Assert.Equal(["runtime", "runtime", "script"], logs.Select(l => l.LogSource));
|
||||
Assert.Single(logs);
|
||||
Assert.Null(logs[0].StepEventId);
|
||||
Assert.Equal("script", logs[0].LogSource);
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
public class TriggerEvalGateLifecycleTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SharedGate_IsProcessOwnedAndRemainsUsableAfterCallerDisposes()
|
||||
{
|
||||
var gate = TriggerEvalGate.Shared(new SiteRuntimeOptions());
|
||||
gate.Dispose();
|
||||
Assert.Same(gate, TriggerEvalGate.Shared(new SiteRuntimeOptions()));
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await gate.WaitAsync(deadline.Token);
|
||||
gate.Release();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OwnedGate_IsDisposedByCaller()
|
||||
{
|
||||
var gate = new TriggerEvalGate(1);
|
||||
gate.Dispose();
|
||||
Assert.Throws<ObjectDisposedException>(() => { _ = gate.WaitAsync(CancellationToken.None); });
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,21 @@ public class SharedScriptServiceTests
|
||||
_service = new SharedScriptService(_repoMock.Object, _auditMock.Object);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("return System.IO.File.ReadAllText(\"x\");")]
|
||||
[InlineData("return noSuchVariable;")]
|
||||
public async Task CreateAndUpdate_RejectInvalidCodeBeforeSaving(string code)
|
||||
{
|
||||
var existing = new SharedScript("Existing", "return 1;");
|
||||
_repoMock.Setup(r => r.GetSharedScriptByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
||||
var create = await _service.CreateSharedScriptAsync("New", code, null, null, "admin");
|
||||
var update = await _service.UpdateSharedScriptAsync(1, code, null, null, "admin");
|
||||
Assert.True(create.IsFailure);
|
||||
Assert.True(update.IsFailure);
|
||||
Assert.Equal("return 1;", existing.Code);
|
||||
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSharedScript_Success()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user