Resolve all 46 low-severity architecture review findings

This commit is contained in:
Joseph Doherty
2026-09-09 22:56:36 -04:00
parent fef3e7675f
commit 9ee402bbcd
139 changed files with 2621 additions and 1082 deletions
@@ -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(
@@ -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) =>
@@ -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(
@@ -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();
@@ -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()
{
@@ -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)));
}
@@ -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();
@@ -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()
{
@@ -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.
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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()
{
@@ -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]);
}
}
@@ -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()
{
@@ -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" />
@@ -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]
@@ -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()
{
@@ -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);
}
}
@@ -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()
{