Resolve all high-severity architecture review findings

This commit is contained in:
Joseph Doherty
2026-09-09 16:13:58 -04:00
parent 8bfe65512e
commit 146a13238f
192 changed files with 3160 additions and 753 deletions
@@ -86,6 +86,46 @@ public class SiteAuditTelemetryActorTests : TestKit
return ack;
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task LargeRows_DrainWithinWireBudget_WithoutAcknowledgingUnsentRows(bool cached)
{
var pending = Enumerable.Range(0, 256).Select(_ => cached ? NewCachedEvent() : NewEvent())
.Select(e => e with { DetailsJson = AuditDetailsCodec.Serialize(AuditDetailsCodec.Deserialize(e.DetailsJson) with {
ErrorDetail = new string('界', 64 * 1024 / 3), ErrorMessage = new string('x', 1024) }) }).ToList();
var remaining = new System.Collections.Concurrent.ConcurrentDictionary<Guid, AuditEvent>(pending.Select(e => new KeyValuePair<Guid, AuditEvent>(e.EventId, e)));
if (cached)
{
_queue.ReadPendingCachedTelemetryAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult<IReadOnlyList<AuditEvent>>(remaining.Values.ToList()));
_trackingStore.GetStatusAsync(Arg.Any<TrackedOperationId>(), Arg.Any<CancellationToken>())
.Returns(c => Task.FromResult<TrackingStatusSnapshot?>(NewSnapshot(c.Arg<TrackedOperationId>())));
}
else
_queue.ReadPendingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult<IReadOnlyList<AuditEvent>>(remaining.Values.ToList()));
var sizes = new System.Collections.Concurrent.ConcurrentBag<int>();
var sent = new System.Collections.Concurrent.ConcurrentDictionary<Guid, bool>();
IngestAck Accept(IEnumerable<AuditEventDto> events, int size)
{
sizes.Add(size + 5);
var ack = new IngestAck();
foreach (var row in events) { sent[Guid.Parse(row.EventId)] = true; ack.AcceptedEventIds.Add(row.EventId); }
return ack;
}
_client.IngestAuditEventsAsync(Arg.Any<AuditEventBatch>(), Arg.Any<CancellationToken>())
.Returns(c => { var b = c.Arg<AuditEventBatch>(); return Task.FromResult(Accept(b.Events, b.CalculateSize())); });
_client.IngestCachedTelemetryAsync(Arg.Any<CachedTelemetryBatch>(), Arg.Any<CancellationToken>())
.Returns(c => { var b = c.Arg<CachedTelemetryBatch>(); return Task.FromResult(Accept(b.Packets.Select(p => p.AuditEvent), b.CalculateSize())); });
_queue.MarkForwardedAsync(Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>())
.Returns(c => { foreach (var id in c.Arg<IReadOnlyList<Guid>>()) { Assert.True(sent.ContainsKey(id)); remaining.TryRemove(id, out _); } return Task.CompletedTask; });
if (cached) CreateActorWithCachedDrain(); else CreateActor();
await AwaitAssertAsync(() => { Assert.Empty(remaining); return Task.CompletedTask; }, TimeSpan.FromSeconds(20));
Assert.All(sizes, size => Assert.InRange(size, 1, 2 * 1024 * 1024));
Assert.Equal(256, sent.Count);
}
[Fact]
public async Task Drain_With_50PendingRows_Sends_OneBatch_Of_50_Then_FlipsToForwarded()
{
@@ -0,0 +1,66 @@
using System.Collections.Concurrent;
using Microsoft.Playwright;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests;
/// <summary>Exercises the deployed activity listener with browser-trusted input and real antiforgery.</summary>
[Collection("Playwright")]
public class SessionActivityTrustedInputTests(PlaywrightFixture fixture)
{
[Fact]
public async Task UI2_SyntheticInputAndPingStayPassive_TrustedKeyRenewsWithAntiforgery()
{
// The fixture logs in using fetch, so this fresh context has no prior trusted input
// and no activity request consuming the listener's 15-second throttle window.
var page = await fixture.NewAuthenticatedPageAsync();
try
{
await page.WaitForFunctionAsync("""
() => performance.getEntriesByType('resource').some(
r => r.name.includes('/js/session-expiry.js'))
""", null, new() { Timeout = 15000 });
var token = page.Locator("input[name='__RequestVerificationToken']").First;
await Assertions.Expect(token).ToHaveAttributeAsync("value", new System.Text.RegularExpressions.Regex(".+"));
var activityRequests = new ConcurrentQueue<IRequest>();
page.Request += (_, request) =>
{
if (new Uri(request.Url).AbsolutePath == "/auth/activity") activityRequests.Enqueue(request);
};
var pingStatus = await page.EvaluateAsync<int>("""
async () => {
for (const type of ['pointerdown', 'keydown', 'wheel', 'touchstart'])
document.dispatchEvent(new Event(type, { bubbles: true }));
return (await fetch('/auth/ping', { cache: 'no-store' })).status;
}
""");
Assert.Equal(200, pingStatus);
// This bounded observation catches fetch scheduled by synthetic events without
// waiting for the real 30-minute idle expiry (covered by fake-clock HTTP tests).
await page.WaitForTimeoutAsync(500);
Assert.Empty(activityRequests);
var responseTask = page.WaitForResponseAsync(
response => new Uri(response.Url).AbsolutePath == "/auth/activity",
new() { Timeout = 10000 });
await page.Keyboard.PressAsync("Shift"); // Browser-generated keydown has isTrusted=true.
var response = await responseTask;
Assert.Equal(204, response.Status);
var request = Assert.Single(activityRequests);
Assert.Equal("POST", request.Method);
var headers = await request.AllHeadersAsync();
Assert.True(headers.TryGetValue("requestverificationtoken", out var sentToken));
Assert.Equal(await token.InputValueAsync(), sentToken);
// A second trusted key inside 15 seconds must not flood the activity endpoint.
await page.Keyboard.PressAsync("Shift");
await page.WaitForTimeoutAsync(500);
Assert.Single(activityRequests);
}
finally
{
await page.Context.CloseAsync();
}
}
}
@@ -42,6 +42,7 @@ public class ApiKeyFormAuditDrillinTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
}
@@ -32,6 +32,7 @@ public class ApiKeysListPageTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
}
@@ -41,6 +41,7 @@ public class SiteFormAuditDrillinTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
}
@@ -91,7 +91,7 @@ public class FlowDebuggerDrillinTests : BunitContext
Assert.Contains("7–10 audit rows per call", markup);
// Load, update and create paths all carry the flag.
Assert.Contains("_traceAllCalls = _existing.TraceAllCalls;", markup);
Assert.Contains("_existing.TraceAllCalls = _traceAllCalls;", markup);
Assert.Contains("TraceAllCalls = _traceAllCalls,", markup);
Assert.Contains("new UpdateApiMethodCommand(_existing.Id, _script, _timeoutSeconds, _params?.Trim(), _returns?.Trim(), _traceAllCalls)", markup);
Assert.Contains("new CreateApiMethodCommand(methodName, _script, _timeoutSeconds, _params?.Trim(), _returns?.Trim(), _traceAllCalls)", markup);
}
}
@@ -22,6 +22,8 @@ public class AuthEndpointsCsrfTests
var builder = WebApplication.CreateBuilder();
builder.Services.AddRouting();
builder.Services.AddAntiforgery();
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Security.SessionActivityTracker>();
builder.Services.AddSingleton(TimeProvider.System);
// Dispose the host: an undisposed WebApplication leaks its config
// PhysicalFileProvider (appsettings reload-watch FileSystemWatcher — a
// process-wide macOS run-loop thread) and a ConsoleLoggerProcessor
@@ -23,6 +23,8 @@ public class AuthPingEndpointTests
var builder = WebApplication.CreateBuilder();
builder.Services.AddRouting();
builder.Services.AddAntiforgery();
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Security.SessionActivityTracker>();
builder.Services.AddSingleton(TimeProvider.System);
// Dispose the host: an undisposed WebApplication leaks its config
// PhysicalFileProvider (appsettings reload-watch FileSystemWatcher — a
// process-wide macOS run-loop thread) and a ConsoleLoggerProcessor
@@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Components.Authorization;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
public class CircuitRevocationRegressionTests
{
[Fact]
public async Task ExplicitServerAutoLogin_DoesNotRemapSyntheticCookiePrincipal()
{
var initial = SessionClaimBuilder.Build("developer", "Developer", [],
new RoleMappingResult(Roles.All, [], true), DateTimeOffset.UtcNow);
var mapper = Substitute.For<IGroupRoleMapper<string>>();
mapper.MapAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
.Returns(new GroupRoleMapping<string>([], Scope: new RoleMappingResult([], [], false)));
var services = new ServiceCollection().AddLogging();
services.AddSingleton(mapper);
services.AddSingleton<TimeProvider>(TimeProvider.System);
services.AddSingleton(Options.Create(new SecurityOptions()));
services.AddSingleton(Options.Create(new ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions { DisableLogin = true }));
services.AddScoped<CookieSessionValidator>();
services.AddSingleton<IHttpContextAccessor>(new HttpContextAccessor { HttpContext = new DefaultHttpContext { User = initial } });
services.AddScoped<AuthenticationStateProvider, CookieAuthenticationStateProvider>();
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var auth = scope.ServiceProvider.GetRequiredService<AuthenticationStateProvider>();
var state = await auth.GetAuthoritativeStateAsync();
Assert.Contains(state.User.Claims, claim => claim.Type == JwtTokenService.RoleClaimType && claim.Value == Roles.Administrator);
await mapper.DidNotReceive().MapAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task SameCircuit_RechecksMappingAndScope_AfterGrantRevocation()
{
var mapping = new RoleMappingResult([Roles.Deployer, Roles.Administrator], ["1"], false);
var mapper = Substitute.For<IGroupRoleMapper<string>>();
mapper.MapAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
.Returns(_ => new GroupRoleMapping<string>(mapping.Roles, Scope: mapping));
var initial = SessionClaimBuilder.Build("alice", "Alice", ["group"], mapping, DateTimeOffset.UtcNow);
var services = new ServiceCollection().AddLogging();
services.AddSingleton(mapper);
var transaction = Substitute.For<IConfigurationCommandTransaction>();
services.AddSingleton(transaction);
services.AddScoped<ConfigurationCommandService>();
services.AddSingleton<TimeProvider>(TimeProvider.System);
services.AddSingleton(Options.Create(new SecurityOptions()));
services.AddScoped<CookieSessionValidator>();
services.AddSingleton<IHttpContextAccessor>(new HttpContextAccessor { HttpContext = new DefaultHttpContext { User = initial } });
services.AddScoped<AuthenticationStateProvider, CookieAuthenticationStateProvider>();
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var auth = scope.ServiceProvider.GetRequiredService<AuthenticationStateProvider>();
var sites = new SiteScopeService(auth);
Assert.True(await sites.IsSiteAllowedAsync(1));
mapping = new RoleMappingResult([Roles.Viewer], ["2"], false);
var refreshed = await auth.GetAuthenticationStateAsync();
Assert.DoesNotContain(Roles.Deployer, refreshed.User.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value));
Assert.False(await sites.IsSiteAllowedAsync(1));
Assert.True(await sites.IsSiteAllowedAsync(2));
// The already-created circuit facade must deny before starting any save transaction.
var commands = scope.ServiceProvider.GetRequiredService<ConfigurationCommandService>();
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => commands.ExecuteAsync(new CreateRoleMappingCommand("blocked", Roles.Viewer)));
await transaction.DidNotReceive().ExecuteAsync(Arg.Any<Func<IServiceProvider, Task<object?>>>());
}
}
@@ -0,0 +1,99 @@
using System.Collections.Concurrent;
using System.Security.Claims;
using Akka.Actor;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
public class ParkedMessagesMutationRegressionTests : BunitContext
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task NonnumericSiteIdentifier_RelaysOnlyWhileCurrentSiteGrantAllows(bool revoke)
{
using var system = ActorSystem.Create("parked-mutation-" + Guid.NewGuid().ToString("N"));
var dispatched = new ConcurrentQueue<SiteEnvelope>();
var comms = new CommunicationService(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance);
comms.SetCommunicationActor(system.ActorOf(Props.Create(() => new ReplyActor(dispatched))));
Services.AddSingleton(comms);
var sites = Substitute.For<ISiteRepository>();
sites.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { new("Plant A", "plant-a") { Id = 42 } });
Services.AddSingleton(sites);
var auth = new MutableAuth("42");
Services.AddSingleton<AuthenticationStateProvider>(auth);
Services.AddScoped<SiteScopeService>();
Services.AddAuthorizationCore();
Services.AddLogging();
Services.AddSingleton(Substitute.For<IDialogService>());
JSInterop.Mode = JSRuntimeMode.Loose;
var page = Render<ParkedMessages>();
await page.Find("select").ChangeAsync(new() { Value = "plant-a" });
page.WaitForAssertion(() => Assert.Contains("ERP", page.Markup));
await page.FindAll("tr").Single(row => row.TextContent.Contains("ERP")).ClickAsync(new());
page.WaitForAssertion(() => Assert.Contains("Retry", page.Markup));
if (revoke) auth.SiteId = "99";
// The same rendered page still contains the old site and row. The click must
// resolve SiteIdentifier through Site.Id and consult the live grant.
await page.FindAll("button").Single(button => button.TextContent.Trim() == "Retry").ClickAsync(new());
if (revoke)
{
Assert.Empty(dispatched);
Assert.Contains("Site access was revoked", page.Markup);
}
else
{
var envelope = Assert.Single(dispatched);
Assert.Equal("plant-a", envelope.SiteId);
var retry = Assert.IsType<ParkedMessageRetryRequest>(envelope.Message);
Assert.Equal("parked-message-1", retry.MessageId);
Assert.Equal("plant-a", retry.SiteId);
}
await system.Terminate();
}
private sealed class MutableAuth(string siteId) : AuthenticationStateProvider
{
public string SiteId { get; set; } = siteId;
public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity(new[] {
new Claim(JwtTokenService.UsernameClaimType, "operator"),
new Claim(JwtTokenService.RoleClaimType, Roles.Deployer),
new Claim(JwtTokenService.SiteIdClaimType, SiteId)
}, "test"))));
}
private sealed class ReplyActor : ReceiveActor
{
public ReplyActor(ConcurrentQueue<SiteEnvelope> dispatched)
{
Receive<SiteEnvelope>(envelope =>
{
if (envelope.Message is ParkedMessageQueryRequest query)
Sender.Tell(new ParkedMessageQueryResponse(query.CorrelationId, envelope.SiteId,
new[] { new ParkedMessageEntry("parked-message-1", "ERP", "Send", "Offline", 2, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow) },
1, 1, 25, true, null, DateTimeOffset.UtcNow));
else if (envelope.Message is ParkedMessageRetryRequest retry)
{
dispatched.Enqueue(envelope);
Sender.Tell(new ParkedMessageRetryResponse(retry.CorrelationId, true));
}
});
}
}
}
@@ -0,0 +1,95 @@
using Akka.Actor;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Admin;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
public class UiAuditRegressionTests : BunitContext
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task LdapCreateFromUi_PersistsAuditWithGeneratedMappingId_OrRollsBack(bool failAudit)
{
using var connection = new SqliteConnection("Data Source=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>().UseSqlite(connection).Options;
Services.AddScoped(_ => new ScadaBridgeDbContext(options));
Services.AddScoped<ISecurityRepository, SecurityRepository>();
Services.AddScoped<IConfigurationReadSession, ConfigurationReadSession>();
Services.AddSingleton(Substitute.For<ISiteRepository>());
Services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
if (failAudit)
{
var failing = Substitute.For<IAuditService>();
failing.LogAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>()).Returns(Task.FromException(new InvalidOperationException("audit unavailable")));
Services.AddSingleton(failing);
}
else Services.AddScoped<IAuditService, AuditService>();
Services.AddSingleton(Substitute.For<IDialogService>());
var user = SessionClaimBuilder.Build("administrator", "Administrator", ["admins"],
new RoleMappingResult([Roles.Administrator], [], true), DateTimeOffset.UtcNow);
Services.AddSingleton<AuthenticationStateProvider>(new StaticAuth(user));
Services.AddAuthorizationCore();
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Services.ConfigurationCommandService>();
Services.AddSingleton<IConfigurationCommandTransaction, ConfigurationCommandTransaction>();
Services.AddScoped<ConfigurationCommitActions>();
Services.AddScoped<IConfigurationCommitActions>(sp => sp.GetRequiredService<ConfigurationCommitActions>());
Services.AddLogging();
using var schema = new ScadaBridgeDbContext(options);
await schema.Database.EnsureCreatedAsync();
var page = Render<LdapMappingForm>();
page.Find("input").Change("NewDirectoryGroup");
page.Find("select").Change(Roles.Viewer);
page.FindAll("button").Single(b => b.TextContent.Contains("Save")).Click();
if (failAudit)
{
page.WaitForAssertion(() => Assert.Contains("audit unavailable", page.Markup));
Assert.Empty(await schema.LdapGroupMappings.Where(x => x.LdapGroupName == "NewDirectoryGroup").ToListAsync());
Assert.Empty(await schema.AuditLogEntries.ToListAsync());
return;
}
page.WaitForAssertion(() => Assert.Single(schema.LdapGroupMappings.AsNoTracking().Where(x => x.LdapGroupName == "NewDirectoryGroup").ToList()));
var mapping = await schema.LdapGroupMappings.AsNoTracking().SingleAsync(x => x.LdapGroupName == "NewDirectoryGroup");
var audit = Assert.Single(await schema.AuditLogEntries.AsNoTracking().ToListAsync());
Assert.Equal(mapping.Id.ToString(), audit.EntityId);
Assert.Equal("Create", audit.Action);
// Existing edit form uses the same durable boundary; generated IDs are retained.
page.Dispose();
var edit = Render<LdapMappingForm>(parameters => parameters.Add(p => p.Id, mapping.Id));
edit.Find("input").Change("RenamedDirectoryGroup");
edit.FindAll("button").Single(b => b.TextContent.Contains("Save")).Click();
edit.WaitForAssertion(() => Assert.Equal("RenamedDirectoryGroup", schema.LdapGroupMappings.AsNoTracking().Single(x => x.Id == mapping.Id).LdapGroupName));
edit.Dispose();
var list = Render<LdapMappings>();
list.WaitForAssertion(() => Assert.Contains("RenamedDirectoryGroup", list.Markup));
var row = list.FindAll("tr").Single(r => r.TextContent.Contains("RenamedDirectoryGroup"));
await row.QuerySelector("button.dropdown-item")!.ClickAsync(new());
list.WaitForAssertion(() => Assert.DoesNotContain("RenamedDirectoryGroup", list.Markup));
Assert.False(await schema.LdapGroupMappings.AnyAsync(x => x.Id == mapping.Id));
var auditRows = await schema.AuditLogEntries.AsNoTracking().OrderBy(x => x.Id).ToListAsync();
Assert.Equal(new[] { "Create", "Update", "Delete" }, auditRows.Select(x => x.Action));
Assert.All(auditRows, row => Assert.Equal(mapping.Id.ToString(), row.EntityId));
}
private sealed class StaticAuth(System.Security.Claims.ClaimsPrincipal principal) : AuthenticationStateProvider
{
public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(principal));
}
}
@@ -59,6 +59,7 @@ public class ConnectionCertificatesTests : BunitContext
Services.AddSingleton<AuthenticationStateProvider>(
new TestAuthStateProvider(BuildPrincipal(roles)));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
}
@@ -54,6 +54,7 @@ public class OpcUaEndpointVerifyTests : BunitContext
Services.AddSingleton<AuthenticationStateProvider>(
new TestAuthStateProvider(BuildPrincipal(roles)));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
_authConfigured = true;
@@ -78,6 +78,7 @@ public class SecuredWritesTests : BunitContext
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
// BunitContext pre-registers a placeholder IAuthorizationService that throws on
// policy evaluation; force the real one so the regions are actually gated.
@@ -0,0 +1,25 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
namespace Microsoft.Extensions.DependencyInjection;
internal static class ConfigurationMutationTestExtensions
{
public static IServiceCollection AddTestConfigurationMutations(this IServiceCollection services)
{
services.TryAddScoped<ConfigurationCommandService>();
services.TryAddSingleton<IAuditService>(_ => Substitute.For<IAuditService>());
services.TryAddSingleton<IConfigurationCommandTransaction, RepositoryTransaction>();
return services;
}
private sealed class RepositoryTransaction(IServiceScopeFactory scopes) : IConfigurationCommandTransaction
{
public async Task<object?> ExecuteAsync(Func<IServiceProvider, Task<object?>> operation)
{
using var scope = scopes.CreateScope();
return await operation(scope.ServiceProvider);
}
}
}
@@ -36,11 +36,12 @@ public class DataConnectionFormTests : BunitContext
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Administrator")
new Claim(JwtTokenService.RoleClaimType, "Designer")
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
}
private IRenderedComponent<DataConnectionForm> RenderForCreateSite(int siteId)
@@ -64,6 +64,7 @@ public class DataConnectionsPageTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
}
private void SeedRepos(
@@ -104,6 +104,7 @@ public class FlowDebuggerPageTests : BunitContext
Services.AddSingleton<AuthenticationStateProvider>(stubAuth);
Services.AddScoped(_ => new SiteScopeService(stubAuth));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
}
private sealed class StubAuthStateProvider : AuthenticationStateProvider
@@ -61,6 +61,7 @@ public class InstanceConfigureAuditDrillinTests : BunitContext
Services.AddSingleton<AuthenticationStateProvider>(authProvider);
Services.AddSingleton(new SiteScopeService(authProvider));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
}
@@ -52,6 +52,7 @@ public class InstanceConfigureBindingBehaviourTests : BunitContext
Services.AddSingleton<AuthenticationStateProvider>(authProvider);
Services.AddSingleton(new SiteScopeService(authProvider));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
}
@@ -66,7 +66,7 @@ public class InstanceConfigureListOverrideTests
Assert.Contains("ClearListOverride", markup);
// Repository-direct delete (the page only edits InstanceConfigure; no new
// server method) — same pattern as native-alarm-source overrides.
Assert.Contains("DeleteInstanceAttributeOverrideAsync", markup);
Assert.Contains("DeleteInstanceAttributeOverrideCommand", markup);
Assert.Contains("HasOverrideRow", markup);
}
@@ -49,11 +49,11 @@ public class InstanceConfigureNativeAlarmTests
{
var markup = InstanceConfigureMarkup;
Assert.Contains("GetNativeAlarmSourceOverridesByInstanceIdAsync", markup);
Assert.Contains("GetNativeAlarmSourceOverrideAsync", markup);
Assert.Contains("AddInstanceNativeAlarmSourceOverrideAsync", markup);
Assert.Contains("UpdateInstanceNativeAlarmSourceOverrideAsync", markup);
Assert.Contains("DeleteInstanceNativeAlarmSourceOverrideAsync", markup);
Assert.Contains("SaveChangesAsync", markup);
Assert.Contains("ExecuteConfigurationAsync", markup);
Assert.Contains("SetInstanceNativeAlarmSourceOverrideCommand", markup);
Assert.Contains("SetInstanceNativeAlarmSourceOverrideCommand", markup);
Assert.Contains("DeleteInstanceNativeAlarmSourceOverrideCommand", markup);
Assert.DoesNotContain("TemplateEngineRepository.SaveChangesAsync", markup);
Assert.Contains("SaveNativeOverride", markup);
Assert.Contains("ClearNativeOverride", markup);
}
@@ -83,6 +83,7 @@ public class InstanceCreateTemplateFieldTests : BunitContext
Services.AddSingleton<AuthenticationStateProvider>(
new TestAuthStateProvider(new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"))));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
JSInterop.Mode = JSRuntimeMode.Loose;
}
@@ -83,6 +83,7 @@ public class TopologyDisableConfirmLabelTests : BunitContext
Services.AddSingleton<AuthenticationStateProvider>(
new TestAuthStateProvider(new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"))));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
JSInterop.Mode = JSRuntimeMode.Loose;
}
@@ -34,6 +34,7 @@ public class ExternalSystemFormAuditDrillinTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
}
@@ -39,6 +39,7 @@ public class ExternalSystemsConnectionStringHidingTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
}
@@ -44,6 +44,7 @@ public class SchemaLibraryPageTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
}
private void SeedSchemas(params SharedSchema[] schemas) =>
@@ -76,6 +76,7 @@ public class ApiMethodFormSandboxDisposalTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
return (Render<ApiMethodFormPage>(), sandbox);
}
@@ -179,6 +180,7 @@ public class SharedScriptFormSandboxDisposalTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
return (Render<SharedScriptFormPage>(), sandbox);
}
@@ -70,6 +70,7 @@ public class TemplateEditInheritedSetTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
}
/// <summary>
@@ -87,11 +87,11 @@ public class TemplateNativeAlarmSourceEditorTests
public void NativeAlarmsCrud_WiresRepositoryAddUpdateDeleteWithSave()
{
var markup = TemplateEditMarkup;
Assert.Contains("AddTemplateNativeAlarmSourceAsync", markup);
Assert.Contains("UpdateTemplateNativeAlarmSourceAsync", markup);
Assert.Contains("DeleteTemplateNativeAlarmSourceAsync", markup);
Assert.Contains("AddTemplateNativeAlarmSourceCommand", markup);
Assert.Contains("UpdateTemplateNativeAlarmSourceCommand", markup);
Assert.Contains("DeleteTemplateNativeAlarmSourceCommand", markup);
Assert.Contains("GetNativeAlarmSourcesByTemplateIdAsync", markup);
Assert.Contains("SaveChangesAsync", markup);
Assert.Contains("ExecuteConfigurationAsync", markup);
// Add/edit/delete handlers are wired to the UI.
Assert.Contains("OpenAddNativeSourceDialog", markup);
Assert.Contains("OpenEditNativeSourceDialog", markup);
@@ -57,6 +57,7 @@ public class NavMenuTests : BunitContext
{
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
// BunitContext pre-registers a placeholder IAuthorizationService that
// throws when AuthorizeView evaluates a policy. Force the real service
@@ -77,6 +77,7 @@ public class AlarmSummaryRenderTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
}
// Renders the page and drives the site picker so the alarm table appears.
@@ -59,6 +59,7 @@ public class AlarmSummaryVirtualizeTests : BunitContext
Services.AddSingleton<AuthenticationStateProvider>(
new TestAuthStateProvider(new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"))));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
}
private IRenderedComponent<AlarmSummaryPage> RenderWithSiteSelected()
@@ -40,6 +40,7 @@ public class FailoverConfirmLabelTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
// BunitContext pre-registers a placeholder IAuthorizationService that throws when
// AuthorizeView evaluates a policy; force the real one so the admin gate is real.
@@ -34,6 +34,7 @@ public class HealthFailoverButtonTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
// BunitContext pre-registers a placeholder IAuthorizationService that throws when
// AuthorizeView evaluates a policy. Force the real service so the admin gate is
@@ -81,6 +81,7 @@ public class AuditLogPagePermissionTests : BunitContext
var user = BuildPrincipal(roles);
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
WireUpPageDependencies();
@@ -55,6 +55,7 @@ public class AuditLogPageScaffoldTests : BunitContext
var user = BuildPrincipal(roles);
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
// The page now hosts AuditFilterBar + AuditResultsGrid which depend on
@@ -92,6 +93,7 @@ public class AuditLogPageScaffoldTests : BunitContext
var user = BuildPrincipal(roles);
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
@@ -64,6 +64,7 @@ public class AuditLogPageTrendTests : BunitContext
var user = BuildPrincipal("Administrator");
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
@@ -104,6 +104,7 @@ public class TransportExportPageTests : BunitContext
var principal = BuildPrincipal("alice", "Designer");
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
}
private static ClaimsPrincipal BuildPrincipal(string username, params string[] roles)
@@ -87,6 +87,7 @@ public class TransportImportPageTests : BunitContext
var principal = BuildPrincipal("alice", "Administrator");
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
}
private static ClaimsPrincipal BuildPrincipal(string username, params string[] roles)
@@ -146,6 +146,7 @@ public class TransportImportSessionDisposeRaceTests : BunitContext
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
}
private static void SetField(object obj, string name, object? value) =>
@@ -37,6 +37,7 @@ public class ExecutionTreePageTests : BunitContext
var user = BuildPrincipal(roles);
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
Services.AddSingleton(_queryService);
@@ -117,6 +117,7 @@ public class HealthPageTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
// The failover control's AuthorizeView evaluates the named RequireAdmin policy, so the
// real policy set has to be registered or the page throws on any card that renders it.
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
@@ -101,6 +101,7 @@ public class NotificationKpisPageTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
}
[Fact]
@@ -43,6 +43,7 @@ public class NotificationListFormTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
}
[Fact]
@@ -29,6 +29,7 @@ public class NotificationListsPageTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
}
[Fact]
@@ -94,6 +94,7 @@ public class NotificationReportDetailModalTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
}
@@ -83,6 +83,7 @@ public class NotificationReportPageTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
// CentralUI-028: the page now injects SiteScopeService — the test user
// has no SiteId claims, so this resolves to system-wide and the
// pre-existing test expectations hold.
@@ -253,6 +253,7 @@ public sealed class QueryStringDrillInTests
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
}
@@ -314,6 +315,7 @@ public sealed class QueryStringDrillInTests
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
}
@@ -400,6 +402,7 @@ public sealed class QueryStringDrillInTests
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
}
}
}
@@ -125,6 +125,7 @@ public class SiteCallsReportPageTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
}
@@ -27,6 +27,7 @@ public class SmsConfigurationPageTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
}
private const string SecretToken = "super-secret-auth-token";
@@ -26,6 +26,7 @@ public class SmtpConfigurationPageTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
}
private static SmtpConfiguration Sample() =>
@@ -61,6 +62,8 @@ public class SmtpConfigurationPageTests : BunitContext
{
repo.GetAllSmtpConfigurationsAsync()
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(configs.ToList()));
foreach (var config in configs)
repo.GetSmtpConfigurationByIdAsync(config.Id).Returns(config);
Services.AddSingleton(repo);
WireAuth();
@@ -138,6 +141,7 @@ public class SmtpConfigurationPageTests : BunitContext
repo.GetAllSmtpConfigurationsAsync()
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(
new List<SmtpConfiguration> { config }));
repo.GetSmtpConfigurationByIdAsync(config.Id).Returns(config);
Services.AddSingleton(repo);
WireAuth();
@@ -61,6 +61,7 @@ public class TemplatesPageTests : BunitContext
var user = new ClaimsPrincipal(identity);
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
}
[Fact]
@@ -102,6 +102,7 @@ public class TopologyLiveUpdatesTimerDisposeRaceTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
}
private void SeedEmptyRepos()
@@ -110,6 +110,7 @@ public class TopologyPageTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
}
private void SeedRepos(
@@ -136,6 +137,45 @@ public class TopologyPageTests : BunitContext
});
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task UI13_ExistingTopologyMoveUsesCurrentSiteScope(bool revoked)
{
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 auth = Services.GetRequiredService<AuthenticationStateProvider>();
var identity = (ClaimsIdentity)(await auth.GetAuthenticationStateAsync()).User.Identity!;
if (revoked) identity.AddClaim(new Claim(JwtTokenService.SiteIdClaimType, "2"));
var handler = typeof(TopologyPage).GetMethod("SubmitMoveInstance",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
await cut.InvokeAsync(() => (Task)handler.Invoke(cut.Instance, [(100, (int?)null)])!);
await _repo.Received(revoked ? 0 : 1).UpdateInstanceAsync(Arg.Any<Instance>(), Arg.Any<CancellationToken>());
Assert.Equal(revoked ? 10 : (int?)null, instance.AreaId);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task UI13_ExistingTopologyCreateUsesCurrentSiteScope(bool revoked)
{
SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }]);
var cut = Render<TopologyPage>();
var auth = Services.GetRequiredService<AuthenticationStateProvider>();
var identity = (ClaimsIdentity)(await auth.GetAuthenticationStateAsync()).User.Identity!;
if (revoked) identity.AddClaim(new Claim(JwtTokenService.SiteIdClaimType, "2"));
var handler = typeof(TopologyPage).GetMethod("SubmitCreateArea",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
await cut.InvokeAsync(() => (Task)handler.Invoke(cut.Instance, [(1, (int?)null, "New area")])!);
await _repo.Received(revoked ? 0 : 1).AddAreaAsync(Arg.Any<Area>(), Arg.Any<CancellationToken>());
}
[Fact]
public void Renders_EmptyState_WhenNoSites()
{
@@ -0,0 +1,39 @@
using Grpc.Core;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
public class AuditWireBudgetTests
{
[Fact]
public void OversizeSoftBudgetRow_TravelsAloneWithoutTruncation()
{
var batch = new AuditEventBatch();
var bytes = AuditWireBudget.InitialBytes;
var row = new AuditEventDto { EventId = "large", ErrorDetail = new string('x', GrpcMessageLimits.AuditBatchBytes + 1) };
Assert.True(AuditWireBudget.TryAppend(batch.Events, row, ref bytes, row.EventId));
Assert.False(AuditWireBudget.TryAppend(batch.Events, new AuditEventDto { EventId = "next" }, ref bytes, "next"));
Assert.Same(row, Assert.Single(batch.Events));
Assert.Equal(row.ErrorDetail, batch.Events[0].ErrorDetail);
Assert.True(bytes < GrpcMessageLimits.MaxMessageBytes);
}
[Fact]
public void HardOversizeRow_DoesNotBlockGoodPrefix_AndFailsExplicitlyWhenFirst()
{
var good = new AuditEventDto { EventId = "good", ErrorMessage = "small" };
var huge = new AuditEventDto { EventId = "retained-id", ErrorDetail = new string('x', GrpcMessageLimits.MaxMessageBytes) };
var batch = new AuditEventBatch();
var bytes = AuditWireBudget.InitialBytes;
Assert.True(AuditWireBudget.TryAppend(batch.Events, good, ref bytes, good.EventId));
Assert.False(AuditWireBudget.TryAppend(batch.Events, huge, ref bytes, huge.EventId));
Assert.Equal("good", Assert.Single(batch.Events).EventId);
var next = new AuditEventBatch();
bytes = AuditWireBudget.InitialBytes;
var ex = Assert.Throws<RpcException>(() => AuditWireBudget.TryAppend(next.Events, huge, ref bytes, huge.EventId));
Assert.Equal(StatusCode.ResourceExhausted, ex.StatusCode);
Assert.Contains("retained-id", ex.Status.Detail);
Assert.Contains("retained locally", ex.Status.Detail);
Assert.Empty(next.Events);
}
}
@@ -19,6 +19,61 @@ public class SitePairChannelProviderDisposalTests
Options.Create(new CommunicationOptions()),
NullLogger<SitePairChannelProvider>.Instance);
[Fact]
public async Task ApplyThenUnavailable_DoesNotReplayAnAmbiguousOperation()
{
using var provider = Build();
provider.UpdateSite("site-1", "http://localhost:15101", "http://localhost:15102");
var effects = 0;
var error = await Record.ExceptionAsync(() => provider.ExecuteAsync("site-1", (channel, ct) =>
{
effects++;
if (effects == 1)
throw new global::Grpc.Core.RpcException(new global::Grpc.Core.Status(global::Grpc.Core.StatusCode.Unavailable, "response connection lost after apply"));
return Task.FromResult(42);
}, CancellationToken.None));
Assert.Equal(1, effects);
Assert.NotNull(error);
}
[Fact]
public async Task SecondaryAppliesThenLosesResponse_AlsoReportsUnknownWithoutAnotherReplay()
{
using var provider = Build();
provider.UpdateSite("site-1", "http://localhost:15101", "http://localhost:15102");
var attempts = 0;
var effects = 0;
var error = await Assert.ThrowsAsync<global::Grpc.Core.RpcException>(() => provider.ExecuteAsync<int>("site-1", (channel, ct) =>
{
if (++attempts == 1) throw GrpcRetrySafety.NotDispatched("starting");
effects++;
throw new global::Grpc.Core.RpcException(new global::Grpc.Core.Status(global::Grpc.Core.StatusCode.Unavailable, "applied, reply lost"));
}, CancellationToken.None));
Assert.Equal(global::Grpc.Core.StatusCode.Aborted, error.StatusCode);
Assert.Contains("OUTCOME_UNKNOWN", error.Status.Detail);
Assert.Equal(2, attempts);
Assert.Equal(1, effects);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task SafeReadOrExplicitPreDispatchRejection_CanRetry(bool safeRead)
{
using var provider = Build();
provider.UpdateSite("site-1", "http://localhost:15101", "http://localhost:15102");
var attempts = 0;
var result = await provider.ExecuteAsync("site-1", (channel, ct) =>
{
if (++attempts == 1)
throw safeRead ? new global::Grpc.Core.RpcException(new global::Grpc.Core.Status(global::Grpc.Core.StatusCode.Unavailable, "lost reply"))
: GrpcRetrySafety.NotDispatched("not ready");
return Task.FromResult(42);
}, CancellationToken.None, retrySafe: safeRead);
Assert.Equal(42, result);
Assert.Equal(2, attempts);
}
[Fact]
public async Task UpdateSite_WhileCallInFlight_ParksTheOldChannelAndLetsTheCallComplete()
{
@@ -44,6 +44,34 @@ public class SiteStreamPullAuditEventsTests : TestKit
?? DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc),
sourceSiteId: "site-1");
[Fact]
public async Task LargeAuditRows_ArePagedByWireBytes_AndCursorMakesProgress()
{
var rows = Enumerable.Range(0, 256).Select(i => ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound, kind: AuditKind.ApiCall, status: AuditStatus.Failed,
occurredAtUtc: DateTime.UtcNow.AddSeconds(i), sourceSiteId: "site-1",
errorDetail: new string('界', 64 * 1024 / 3), errorMessage: new string('x', 2048))).ToList();
var queue = Substitute.For<ISiteAuditQueue>();
queue.ReadPendingSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(c => (IReadOnlyList<AuditEvent>)rows.Skip(c.ArgAt<string?>(2) is { } id
? rows.FindIndex(e => e.EventId.ToString() == id) + 1 : 0).Take(c.ArgAt<int>(1)).ToList());
var server = CreateServer();
server.SetSiteAuditQueue(queue);
var request = new PullAuditEventsRequest { BatchSize = 256 };
var seen = new HashSet<string>();
while (true)
{
var response = await server.PullAuditEvents(request, NewContext());
Assert.True(response.CalculateSize() + 5 <= 2 * 1024 * 1024);
foreach (var row in response.Events) Assert.True(seen.Add(row.EventId));
if (!response.MoreAvailable) break;
Assert.NotEmpty(response.Events);
request.AfterId = response.Events[^1].EventId;
request.SinceUtc = response.Events[^1].OccurredAtUtc;
}
Assert.Equal(256, seen.Count);
}
[Fact]
public async Task PullAuditEvents_NoQueueWired_ReturnsEmptyResponse()
{
@@ -27,6 +27,25 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
_fixture = fixture;
}
[SkippableFact]
public async Task CM9_ExistingOperationCannotBeAdvancedByAnotherSite()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var id = TrackedOperationId.New();
await using var context = CreateContext();
var repository = new SiteCallAuditRepository(context);
await repository.UpsertAsync(NewRow(id, status: "Submitted", sourceSite: "owner-b"));
var failure = await Assert.ThrowsAsync<Microsoft.Data.SqlClient.SqlException>(() =>
repository.UpsertAsync(NewRow(id, status: "Delivered", sourceSite: "attacker-a", retryCount: 99)));
Assert.Equal(51001, failure.Number);
var stored = await repository.GetAsync(id);
Assert.NotNull(stored);
Assert.Equal("owner-b", stored.SourceSite);
Assert.Equal("Submitted", stored.Status);
Assert.Equal(0, stored.RetryCount);
}
[SkippableFact]
public async Task UpsertAsync_FreshId_InsertsOneRow()
{
@@ -897,7 +916,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
TrackedOperationId = id,
Channel = "ApiOutbound",
Target = "ERP.GetOrder",
SourceSite = sourceSite ?? NewSiteId(),
// Every lifecycle packet for one operation has the same owning site.
SourceSite = sourceSite ?? $"test-site-{id.Value:N}",
SourceNode = sourceNode,
Status = status,
RetryCount = retryCount,
@@ -0,0 +1,247 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
public class HighReviewRegressionTests : TestKit
{
private readonly ISiteHealthCollector _health = Substitute.For<ISiteHealthCollector>();
private static IDataConnection Adapter()
{
var adapter = Substitute.For<IDataConnection, IAlarmSubscribableConnection>();
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>()).Returns(Task.CompletedTask);
adapter.SubscribeAsync(Arg.Any<string>(), Arg.Any<SubscriptionCallback>(), Arg.Any<CancellationToken>()).Returns("handle");
adapter.ReadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(new ReadResult(false, null, null));
return adapter;
}
private IActorRef Actor(IDataConnection adapter) => Sys.ActorOf(Props.Create(() => new DataConnectionActor(
"conn", adapter, new DataConnectionOptions { ReconnectInterval = TimeSpan.FromMilliseconds(50), TagResolutionRetryInterval = TimeSpan.FromHours(1), SeedReadMaxAttempts = 1 },
_health, Substitute.For<IDataConnectionFactory>(), "OpcUa")));
private void Connected() => AwaitCondition(() => _health.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "UpdateConnectionHealth" && c.GetArguments()[1] is ConnectionHealth.Connected));
private static SubscribeTagsRequest Subscribe(string correlation = "s") => new(correlation, "instance", "conn", ["tag"], DateTimeOffset.UtcNow);
private DataConnectionHealthReport Health(IActorRef actor)
{
actor.Tell(new DataConnectionActor.GetHealthReport());
return ExpectMsg<DataConnectionHealthReport>();
}
[Fact]
public void ResolutionTick_DoesNotDuplicateAnInitialSubscribeAlreadyInFlight()
{
var adapter = new FakeBatchDataConnection();
adapter.FailingTags.Add("tag");
var actor = Actor(adapter);
var first = CreateTestProbe();
actor.Tell(Subscribe(), first.Ref);
first.ExpectMsg<TagValueUpdate>();
first.ExpectMsg<SubscribeTagsResponse>();
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
adapter.SubscribeGate = gate.Task;
adapter.FailingTags.Clear();
var second = CreateTestProbe();
actor.Tell(Subscribe("second") with { InstanceUniqueName = "second-instance" }, second.Ref);
AwaitCondition(() => adapter.SubscribeBatches.Count == 2);
var unexpected = CreateTestProbe();
adapter.BatchSubscribeThrows = () => { unexpected.Ref.Tell("duplicate subscribe"); return null; };
actor.Tell(new DataConnectionActor.RetryTagResolution());
try { unexpected.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); }
finally { gate.SetResult(); }
}
[Fact]
public void OriginalOwnerDies_InFlightHandleStillServesDifferentInstance()
{
var adapter = Adapter();
var gate = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
adapter.SubscribeAsync("tag", Arg.Any<SubscriptionCallback>(), Arg.Any<CancellationToken>()).Returns(gate.Task);
var actor = Actor(adapter);
var a = CreateTestProbe(); var b = CreateTestProbe();
actor.Tell(Subscribe(), a.Ref);
AwaitCondition(() => adapter.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SubscribeAsync"));
actor.Tell(Subscribe("b") with { InstanceUniqueName = "other-instance" }, b.Ref);
b.ExpectMsg<SubscribeTagsResponse>();
Health(actor); // mailbox barrier after B's registration
var beforeDeath = _health.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "UpdateTagResolution");
Sys.Stop(a.Ref);
AwaitCondition(() => _health.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "UpdateTagResolution") > beforeDeath);
// The remaining instance still needs the tag, even though A started the I/O.
Assert.Equal(1, Health(actor).TotalSubscribedTags);
gate.SetResult("shared-handle");
AwaitAssert(() => Assert.Equal(1, Health(actor).ResolvedTags), TimeSpan.FromSeconds(1));
Assert.DoesNotContain(adapter.ReceivedCalls(), c => c.GetMethodInfo().Name == "UnsubscribeAsync");
}
[Fact]
public void SubscriberTermination_DuringSubscribe_ReleasesLateHandleWithoutResurrection()
{
var adapter = Adapter();
var gate = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
adapter.SubscribeAsync("tag", Arg.Any<SubscriptionCallback>(), Arg.Any<CancellationToken>()).Returns(gate.Task);
var actor = Actor(adapter);
var subscriber = CreateTestProbe();
actor.Tell(Subscribe(), subscriber.Ref);
AwaitCondition(() => adapter.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SubscribeAsync"));
Assert.Equal(1, Health(actor).TotalSubscribedTags);
Sys.Stop(subscriber.Ref);
AwaitAssert(() => Assert.Equal(0, Health(actor).TotalSubscribedTags));
gate.SetResult("late-handle");
AwaitCondition(() => adapter.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "UnsubscribeAsync" && Equals(c.GetArguments()[0], "late-handle")));
Assert.Equal(0, Health(actor).ResolvedTags);
}
[Fact]
public void Replacement_DuringSubscribe_AdoptsOnlyStillRequestedTags()
{
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var adapter = new FakeBatchDataConnection { SubscribeGate = gate.Task };
var actor = Actor(adapter);
var old = CreateTestProbe(); var current = CreateTestProbe();
actor.Tell(new SubscribeTagsRequest("old", "instance", "conn", ["keep", "drop"], DateTimeOffset.UtcNow), old.Ref);
AwaitCondition(() => adapter.SubscribeBatches.Count == 1);
actor.Tell(new SubscribeTagsRequest("new", "instance", "conn", ["keep"], DateTimeOffset.UtcNow), current.Ref);
current.ExpectMsg<SubscribeTagsResponse>();
Assert.Equal(1, Health(actor).TotalSubscribedTags);
gate.SetResult();
AwaitCondition(() => adapter.UnsubscribeBatches.SelectMany(b => b).Contains("sub-2"));
AwaitAssert(() => Assert.Equal(1, Health(actor).ResolvedTags));
Assert.DoesNotContain("sub-1", adapter.UnsubscribeBatches.SelectMany(b => b));
Sys.Stop(old.Ref);
Assert.Equal(1, Health(actor).TotalSubscribedTags);
}
[Fact]
public void SubscriberTermination_DuringResolution_ReleasesLateHandle()
{
var adapter = new FakeBatchDataConnection();
adapter.FailingTags.Add("tag");
var options = new DataConnectionOptions { TagResolutionRetryInterval = TimeSpan.FromMilliseconds(500), SeedReadMaxAttempts = 1 };
var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor("conn", adapter, options, _health, Substitute.For<IDataConnectionFactory>(), "OpcUa")));
var subscriber = CreateTestProbe();
actor.Tell(Subscribe(), subscriber.Ref);
subscriber.ExpectMsg<TagValueUpdate>();
subscriber.ExpectMsg<SubscribeTagsResponse>();
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
adapter.SubscribeGate = gate.Task;
adapter.FailingTags.Clear();
AwaitCondition(() => adapter.SubscribeBatches.Count == 2);
Sys.Stop(subscriber.Ref);
AwaitAssert(() => Assert.Equal(0, Health(actor).TotalSubscribedTags));
gate.SetResult();
AwaitCondition(() => adapter.UnsubscribeBatches.SelectMany(b => b).Any());
Assert.Equal(0, Health(actor).ResolvedTags);
}
[Fact]
public void DisconnectedSubscribeRetries_KeepOnlyLatestRequest()
{
var adapter = Adapter();
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>()).Returns(gate.Task);
var actor = Actor(adapter);
for (var i = 0; i < 100; i++) actor.Tell(Subscribe(i.ToString()));
Health(actor); // all retries have reached the disconnected-state queue
gate.SetResult();
ExpectMsg<SubscribeTagsResponse>(r => r.CorrelationId == "99");
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
Assert.Single(adapter.ReceivedCalls(), c => c.GetMethodInfo().Name == "SubscribeAsync");
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void DisconnectedWrites_AreRejectedAndNeverReplayed(bool reconnect)
{
var adapter = Adapter();
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
if (!reconnect) adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>()).Returns(gate.Task);
var actor = Actor(adapter);
if (reconnect)
{
Connected();
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>()).Returns(gate.Task);
adapter.Disconnected += Raise.Event<Action>();
AwaitCondition(() => _health.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "UpdateConnectionHealth" && c.GetArguments()[1] is ConnectionHealth.Disconnected));
}
actor.Tell(new WriteTagRequest("single", "conn", "tag", 42, DateTimeOffset.UtcNow));
ExpectMsg<WriteTagResponse>(r => !r.Success && r.CorrelationId == "single", TimeSpan.FromSeconds(1));
actor.Tell(new WriteTagBatchRequest("batch", "conn", new Dictionary<string, object?> { ["tag"] = 42 }, null, null, DateTimeOffset.UtcNow));
ExpectMsg<WriteTagBatchResponse>(r => !r.Success && r.CorrelationId == "batch", TimeSpan.FromSeconds(1));
gate.SetResult();
actor.Tell(Subscribe());
ExpectMsg<SubscribeTagsResponse>();
Assert.DoesNotContain(adapter.ReceivedCalls(), c => c.GetMethodInfo().Name is "WriteAsync" or "WriteBatchAsync");
}
[Fact]
public void SubscriberTermination_ReleasesLastTagHandle()
{
var adapter = Adapter();
var actor = Actor(adapter);
var subscriber = CreateTestProbe();
actor.Tell(Subscribe(), subscriber.Ref);
subscriber.ExpectMsg<SubscribeTagsResponse>();
Sys.Stop(subscriber.Ref);
AwaitCondition(() => adapter.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "UnsubscribeAsync" && Equals(c.GetArguments()[0], "handle")), TimeSpan.FromSeconds(1));
}
[Fact]
public void OldOwnerUnsubscribe_DoesNotRemoveReplacementTags()
{
var adapter = Adapter();
SubscriptionCallback? callback = null;
adapter.SubscribeAsync("tag", Arg.Do<SubscriptionCallback>(c => callback = c), Arg.Any<CancellationToken>()).Returns("handle");
var actor = Actor(adapter);
var old = CreateTestProbe();
var current = CreateTestProbe();
actor.Tell(Subscribe("old"), old.Ref);
old.ExpectMsg<SubscribeTagsResponse>();
actor.Tell(Subscribe("new"), current.Ref);
current.ExpectMsg<SubscribeTagsResponse>();
actor.Tell(new UnsubscribeTagsRequest("u", "instance", "conn", DateTimeOffset.UtcNow), old.Ref);
actor.Tell(Subscribe("barrier"), current.Ref);
current.ExpectMsg<SubscribeTagsResponse>();
callback!("tag", new TagValue(7, QualityCode.Good, DateTimeOffset.UtcNow));
current.ExpectMsg<TagValueUpdate>(u => Equals(u.Value, 7));
Assert.DoesNotContain(adapter.ReceivedCalls(), c => c.GetMethodInfo().Name == "UnsubscribeAsync");
}
[Fact]
public void DuplicateCompletion_WithLiveHandle_DoesNotUnsubscribeIt()
{
var adapter = Adapter();
var actor = Actor(adapter);
actor.Tell(Subscribe());
ExpectMsg<SubscribeTagsResponse>();
actor.Tell(new DataConnectionActor.SubscribeCompleted(Subscribe("duplicate"), TestActor,
[new DataConnectionActor.SubscribeTagResult("tag", false, true, "handle", null)], [], 0, adapter));
ExpectMsg<SubscribeTagsResponse>();
Assert.DoesNotContain(adapter.ReceivedCalls(), c => c.GetMethodInfo().Name == "UnsubscribeAsync");
}
[Fact]
public void SourceScopedSnapshotEnd_IsNotBroadcastToOtherSources()
{
var adapter = Adapter();
var alarm = (IAlarmSubscribableConnection)adapter;
AlarmTransitionCallback? callback = null;
alarm.SubscribeAlarmsAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Do<AlarmTransitionCallback>(c => callback = c), Arg.Any<CancellationToken>()).Returns("alarm-handle");
var actor = Actor(adapter);
var a = CreateTestProbe(); var b = CreateTestProbe();
actor.Tell(new SubscribeAlarmsRequest("a", "a", "conn", "A", null, DateTimeOffset.UtcNow), a.Ref);
a.ExpectMsg<SubscribeAlarmsResponse>();
actor.Tell(new SubscribeAlarmsRequest("b", "b", "conn", "B", null, DateTimeOffset.UtcNow), b.Ref);
b.ExpectMsg<SubscribeAlarmsResponse>();
callback!(new NativeAlarmTransition("", "B", "", AlarmTransitionKind.SnapshotComplete,
new AlarmConditionState(false, false, null, AlarmShelveState.Unshelved, false, 0), "", "", "", "", "", null, DateTimeOffset.UtcNow, "", ""));
b.ExpectMsg<NativeAlarmTransitionUpdate>();
a.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
}
@@ -26,6 +26,30 @@ public class MxGatewayBatchSeamTests
["ReadTimeoutMs"] = "2000",
};
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task UnsubscribeOldHandle_PreservesNewHandleRouting(bool batch)
{
var fake = new FakeMxGatewayClient();
await using var adapter = NewAdapter(fake);
await adapter.ConnectAsync(Details());
var received = new List<object?>();
var old = await adapter.SubscribeAsync("A.x", (_, value) => received.Add(value.Value));
await adapter.SubscribeAsync("A.x", (_, value) => received.Add(value.Value));
if (batch) await adapter.UnsubscribeBatchAsync([old]);
else await adapter.UnsubscribeAsync(old);
await WaitUntilAsync(() => fake.OnUpdate != null);
fake.OnUpdate!(new MxValueUpdate("A.x", 7, QualityCode.Good, DateTimeOffset.UtcNow));
Assert.Equal([7], received);
}
private static async Task WaitUntilAsync(Func<bool> condition)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
while (!condition()) await Task.Delay(10, timeout.Token);
}
[Fact]
public async Task SubscribeBatch_IssuesExactlyOneBulkCallPerBatch()
{
@@ -0,0 +1,32 @@
using System.Reflection;
using Opc.Ua;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
public class RealOpcUaSnapshotScopeTests
{
[Fact]
public void RefreshBoundaries_PreserveSourceAndHaveDistinctEpochs()
{
var client = new RealOpcUaClient();
var events = new List<NativeAlarmTransition>();
var handler = typeof(RealOpcUaClient).GetMethod("HandleAlarmEvent", BindingFlags.Instance | BindingFlags.NonPublic)!;
void Emit(string handle, string source, NodeId type) => handler.Invoke(client,
[handle, source, new EventFieldList { EventFields = new VariantCollection { new(type), new(), new(), new(), new(), new() } }, (Action<NativeAlarmTransition>)events.Add]);
Emit("a", "A", ObjectTypeIds.RefreshStartEventType);
Emit("b", "B", ObjectTypeIds.RefreshStartEventType);
Emit("a", "A", ObjectTypeIds.RefreshEndEventType);
Emit("b", "B", ObjectTypeIds.RefreshEndEventType);
Assert.Equal(4, events.Count);
Assert.Equal(["SnapshotStart", "SnapshotStart", "SnapshotComplete", "SnapshotComplete"], events.Select(e => e.Kind.ToString()));
Assert.Equal(["A", "B", "A", "B"], events.Select(e => e.SourceObjectReference));
var epoch = typeof(NativeAlarmTransition).GetProperty("SnapshotId")!;
Assert.NotNull(epoch);
Assert.NotNull(epoch.GetValue(events[0]));
Assert.Equal(epoch.GetValue(events[0]), epoch.GetValue(events[2]));
Assert.Equal(epoch.GetValue(events[1]), epoch.GetValue(events[3]));
Assert.NotEqual(epoch.GetValue(events[0]), epoch.GetValue(events[1]));
}
}
@@ -545,15 +545,21 @@ public class DataConnectionActorTests : TestKit
backupAdapter.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ConnectAsync"),
TimeSpan.FromSeconds(5));
// Release the in-flight subscribe; the loop resumes and subscribes t2.
// Demand is registered before I/O (DC-1), so failover independently recovers
// both tags on backup even while the original primary operation is parked.
AwaitCondition(() => backupAdapter.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "SubscribeAsync") == 2,
TimeSpan.FromSeconds(5));
// Release the in-flight subscribe; its own loop must still finish on primary.
subscribeGate.SetResult("sub-primary-1");
// t2 must go to the ORIGINAL (primary) adapter, never the swapped-in backup.
AwaitCondition(() =>
primaryAdapter.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "SubscribeAsync") >= 2,
TimeSpan.FromSeconds(5));
Assert.DoesNotContain(backupAdapter.ReceivedCalls(),
c => c.GetMethodInfo().Name == "SubscribeAsync");
Assert.Contains(primaryAdapter.ReceivedCalls(),
c => c.GetMethodInfo().Name == "SubscribeAsync" && Equals(c.GetArguments()[0], "t2"));
Assert.Equal(2, backupAdapter.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "SubscribeAsync"));
}
// ── PLAN-03 Task 17 (P3): tag→instances reverse index for the value fan-out hot path ──
@@ -15,8 +15,9 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <c>infra/docker-compose.yml</c>). Marked <c>[SkippableFact]</c> so it reports
/// Skipped — not failed — when the endpoint is unreachable OR does not expose
/// Alarms &amp; Conditions (no ConditionRefresh snapshot arrives). The OPC PLC
/// simulator does not reliably expose A&amp;C; see
/// <c>docs/test_infra/test_infra.md</c> for the alarm-capable-server requirement.
/// fixture exposes two Boiler event sources used by the multi-source regression.
/// That regression skips only when the endpoint is unavailable; missing fixture
/// nodes or incomplete refresh frames fail once the server is reachable.
///
/// The pure field→transition mapping is covered without a server by
/// <see cref="Adapters.OpcUaAlarmMapperTests"/>; this test proves the live
@@ -78,4 +79,59 @@ public class OpcUaAlarmLiveSmokeTests
await adapter.DisconnectAsync();
}
}
[SkippableFact]
public async Task TwoBoilerSources_KeepDistinctRefreshScopesAndEpochs()
{
await using var client = new RealOpcUaClient();
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(20));
try
{
await client.ConnectAsync(EndpointUrl,
new OpcUaConnectionOptions(OperationTimeoutMs: 5000), deadline.Token);
}
catch (Exception ex)
{
Skip.If(true, $"OPC UA test server unavailable: {ex.Message}");
return;
}
// Distinct event notifier objects exposed by the local OPC PLC fixture.
// Adding B requests a subscription-wide ConditionRefresh, so A receives
// another refresh as well. Each monitored item must keep its own frame.
const string sourceA = "nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=5017";
const string sourceB = "nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=15070";
var transitions = new System.Collections.Concurrent.ConcurrentQueue<NativeAlarmTransition>();
var bothComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
void Capture(NativeAlarmTransition transition)
{
transitions.Enqueue(transition);
if (transitions.Any(t => t.Kind == AlarmTransitionKind.SnapshotComplete && t.SourceObjectReference == sourceA)
&& transitions.Any(t => t.Kind == AlarmTransitionKind.SnapshotComplete && t.SourceObjectReference == sourceB))
bothComplete.TrySetResult();
}
await client.CreateAlarmSubscriptionAsync(sourceA, null, Capture, deadline.Token);
await client.CreateAlarmSubscriptionAsync(sourceB, null, Capture, deadline.Token);
await bothComplete.Task.WaitAsync(deadline.Token);
var boundaries = transitions.Where(t => t.Kind is AlarmTransitionKind.SnapshotStart
or AlarmTransitionKind.SnapshotComplete).ToArray();
foreach (var source in new[] { sourceA, sourceB })
{
var frames = boundaries.Where(t => t.SourceObjectReference == source).ToArray();
Assert.Contains(frames, t => t.Kind == AlarmTransitionKind.SnapshotStart);
Assert.Contains(frames, t => t.Kind == AlarmTransitionKind.SnapshotComplete);
Assert.All(frames, t =>
{
Assert.Equal(source, t.SnapshotSourceReference);
Assert.NotNull(t.SnapshotId);
});
foreach (var end in frames.Where(t => t.Kind == AlarmTransitionKind.SnapshotComplete))
Assert.Single(frames, t => t.Kind == AlarmTransitionKind.SnapshotStart && t.SnapshotId == end.SnapshotId);
}
Assert.Empty(boundaries.Where(t => t.SourceObjectReference == sourceA).Select(t => t.SnapshotId)
.Intersect(boundaries.Where(t => t.SourceObjectReference == sourceB).Select(t => t.SnapshotId)));
}
}
@@ -83,6 +83,51 @@ public class CentralControlAuthInterceptorTests
=> interceptor.UnaryServerHandler<string, string>(
"request", context, (_, _) => Task.FromResult("ok"));
[Theory]
[InlineData("notification")]
[InlineData("reconcile")]
[InlineData("health")]
[InlineData("heartbeat")]
[InlineData("audit")]
[InlineData("cached-audit")]
[InlineData("cached-operational")]
public async Task VerifiedSiteA_CannotSubmitSiteBOwnership(string kind)
{
var service = new CentralControlGrpcService(
NullLogger<CentralControlGrpcService>.Instance,
Microsoft.Extensions.Options.Options.Create(new ZB.MOM.WW.ScadaBridge.Communication.CommunicationOptions()));
object request = kind switch
{
"notification" => new NotificationSubmitDto { SourceSiteId = SiteB },
"reconcile" => new ReconcileSiteRequestDto { SiteIdentifier = SiteB },
"health" => new SiteHealthReportDto { SiteId = SiteB },
"heartbeat" => new HeartbeatDto { SiteId = SiteB },
"audit" => new AuditEventBatch { Events = { new AuditEventDto { SourceSiteId = SiteA }, new AuditEventDto { SourceSiteId = SiteB } } },
_ => new CachedTelemetryBatch { Packets = { new CachedTelemetryPacket {
AuditEvent = new AuditEventDto { SourceSiteId = kind == "cached-audit" ? SiteB : SiteA },
Operational = new SiteCallOperationalDto { SourceSite = kind == "cached-operational" ? SiteB : SiteA }
} } }
};
var continued = false;
var ex = await Assert.ThrowsAsync<RpcException>(() => CreateInterceptor().UnaryServerHandler<object, object>(
request, CreateContext(ControlMethod, SiteA, $"Bearer {SiteAKey}"), async (r, context) =>
{
continued = true;
return r switch
{
NotificationSubmitDto n => await service.SubmitNotification(n, context),
ReconcileSiteRequestDto n => await service.ReconcileSite(n, context),
SiteHealthReportDto n => await service.ReportSiteHealth(n, context),
HeartbeatDto n => await service.Heartbeat(n, context),
AuditEventBatch n => await service.IngestAuditEvents(n, context),
CachedTelemetryBatch n => await service.IngestCachedTelemetry(n, context),
_ => throw new InvalidOperationException()
};
}));
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
Assert.False(continued); // no actor dispatch, token mint, or partial batch ingest
}
[Fact]
public async Task NonGatedMethod_PassesThrough()
{
@@ -47,6 +47,7 @@ public class CentralControlEndToEndTests : IAsyncLifetime
// bridge can be asserted without extracting ids out of a decoded AuditEvent.
private static readonly Guid AcceptedId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private int _dispatches;
private IHost _host = null!;
private TestServer _server = null!;
private ActorSystem _actorSystem = null!;
@@ -56,7 +57,7 @@ public class CentralControlEndToEndTests : IAsyncLifetime
public async Task InitializeAsync()
{
_actorSystem = ActorSystem.Create("centralcontrol-e2e-test");
var stub = _actorSystem.ActorOf(Props.Create(() => new StubCentralActor(AcceptedId)), "central-stub");
var stub = _actorSystem.ActorOf(Props.Create(() => new StubCentralActor(AcceptedId, () => Interlocked.Increment(ref _dispatches))), "central-stub");
_service = new CentralControlGrpcService(
NullLogger<CentralControlGrpcService>.Instance,
@@ -117,6 +118,35 @@ public class CentralControlEndToEndTests : IAsyncLifetime
// ---- Auth positives / negatives, all through the real pipeline ----
[Theory]
[InlineData("reconcile")]
[InlineData("notification")]
[InlineData("audit")]
[InlineData("cached")]
[InlineData("health")]
[InlineData("heartbeat")]
public async Task SiteAKey_WithForeignOwner_IsDeniedBeforeAnyActorDispatch(string rpc)
{
using var channel = Channel(SiteAKey, SiteA);
var client = new CentralControlService.CentralControlServiceClient(channel);
var deadline = DateTime.UtcNow.AddSeconds(2);
var error = await Assert.ThrowsAsync<RpcException>(async () =>
{
switch (rpc)
{
case "reconcile": await client.ReconcileSiteAsync(new ReconcileSiteRequestDto { SiteIdentifier = SiteB }, deadline: deadline); break;
case "notification": await client.SubmitNotificationAsync(new NotificationSubmitDto { SourceSiteId = SiteB }, deadline: deadline); break;
case "audit": await client.IngestAuditEventsAsync(new AuditEventBatch { Events = { new AuditEventDto { SourceSiteId = SiteA }, new AuditEventDto { SourceSiteId = SiteB } } }, deadline: deadline); break;
case "cached": await client.IngestCachedTelemetryAsync(new CachedTelemetryBatch { Packets = { new CachedTelemetryPacket {
AuditEvent = new AuditEventDto { SourceSiteId = SiteA }, Operational = new SiteCallOperationalDto { SourceSite = SiteB } } } }, deadline: deadline); break;
case "health": await client.ReportSiteHealthAsync(new SiteHealthReportDto { SiteId = SiteB }, deadline: deadline); break;
default: await client.HeartbeatAsync(new HeartbeatDto { SiteId = SiteB }, deadline: deadline); break;
}
});
Assert.Equal(StatusCode.PermissionDenied, error.StatusCode);
Assert.Equal(0, Volatile.Read(ref _dispatches));
}
[Fact]
public async Task CorrectSiteAndKey_ReachesTheService_OnAUnaryCall()
{
@@ -211,6 +241,7 @@ public class CentralControlEndToEndTests : IAsyncLifetime
{
NotificationId = Guid.NewGuid().ToString(),
ListName = "ops",
SourceSiteId = SiteA,
SiteEnqueuedAt = Timestamp.FromDateTime(
DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)),
};
@@ -249,13 +280,14 @@ public class CentralControlEndToEndTests : IAsyncLifetime
/// </summary>
private sealed class StubCentralActor : ReceiveActor
{
public StubCentralActor(Guid acceptedId)
public StubCentralActor(Guid acceptedId, Action dispatched)
{
Receive<NotificationSubmit>(msg =>
Sender.Tell(new NotificationSubmitAck(msg.NotificationId, Accepted: true, Error: null)));
Receive<NotificationSubmit>(msg => { dispatched();
Sender.Tell(new NotificationSubmitAck(msg.NotificationId, Accepted: true, Error: null)); });
Receive<IngestAuditEventsCommand>(_ =>
Sender.Tell(new IngestAuditEventsReply(new[] { acceptedId })));
Receive<IngestAuditEventsCommand>(_ => { dispatched();
Sender.Tell(new IngestAuditEventsReply(new[] { acceptedId })); });
ReceiveAny(_ => dispatched());
}
}
}
@@ -83,7 +83,7 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime
/// </summary>
private GrpcChannel Channel(string? key, string siteId = "site-a")
{
var options = new GrpcChannelOptions { HttpHandler = _server.CreateHandler() };
var options = new GrpcChannelOptions { HttpHandler = _server.CreateHandler() }.WithMessageLimits();
if (key is not null)
{
options.WithSiteCredentials(new FixedPskProvider(key), siteId);
@@ -116,10 +116,25 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime
IServerStreamWriter<SiteStreamEvent> responseStream,
ServerCallContext context)
{
await responseStream.WriteAsync(new SiteStreamEvent { CorrelationId = request.CorrelationId });
await responseStream.WriteAsync(new SiteStreamEvent
{
CorrelationId = request.CorrelationId == "large-audit-window"
? new string('x', 17 * 1024 * 1024)
: request.CorrelationId,
});
}
}
[Fact]
public async Task CM1_ControlPlaneChannelReceivesWorstCaseAuditSizedMessage()
{
using var channel = Channel(SiteKey);
var client = new SiteStreamService.SiteStreamServiceClient(channel);
using var call = client.SubscribeInstance(new InstanceStreamRequest { CorrelationId = "large-audit-window" });
Assert.True(await call.ResponseStream.MoveNext(CancellationToken.None));
Assert.Equal(17 * 1024 * 1024, call.ResponseStream.Current.CorrelationId.Length);
}
[Fact]
public async Task CorrectKey_IsAccepted_OnAUnaryCall()
{
@@ -1,3 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using System.Collections.Concurrent;
using Akka.Actor;
using Microsoft.AspNetCore.Builder;
@@ -69,16 +70,32 @@ public class GrpcCentralTransportTests : IAsyncLifetime
/// instead of reaching its TestServer. See <see cref="GrpcStatusHandler"/>.
/// </summary>
private Grpc.Core.StatusCode? _nodeAForcedStatus;
private bool _loseReconcileResponse;
private HttpMessageHandler HandlerFor(string endpoint) => endpoint == EndpointA
? new GrpcStatusHandler(
new ToggleHandler(_nodeA.Server.CreateHandler(), () => _nodeA.IsUp),
new LostReconcileResponseHandler(new ToggleHandler(_nodeA.Server.CreateHandler(), () => _nodeA.IsUp), () => _loseReconcileResponse),
() => _nodeAForcedStatus)
: new ToggleHandler(_nodeB.Server.CreateHandler(), () => _nodeB.IsUp);
private GrpcCentralTransport NewTransport(CentralChannelProvider provider, CommunicationOptions? options = null)
=> new(provider, options ?? new CommunicationOptions(), NullLogger<GrpcCentralTransport>.Instance);
[Fact]
public void ReconcileAppliedThenResponseLost_DoesNotMintAgainOnPeer()
{
using var provider = NewProvider();
_loseReconcileResponse = true;
var inbox = new Capture(_system);
NewTransport(provider).ReconcileSite(new ReconcileSiteRequest(SiteA, "node-a", new Dictionary<string, string>()), inbox.Ref);
var failure = Assert.IsType<Akka.Actor.Status.Failure>(inbox.Receive(TimeSpan.FromSeconds(5)));
var rpc = Assert.IsType<Grpc.Core.RpcException>(failure.Cause);
Assert.Equal(Grpc.Core.StatusCode.Aborted, rpc.StatusCode);
Assert.Contains("OUTCOME_UNKNOWN", rpc.Status.Detail);
Assert.Equal(1, _nodeA.ReconcileCount);
Assert.Equal(0, _nodeB.ReconcileCount);
}
[Fact]
public async Task HappyPath_ReachesThePreferredNode_AndRoutesTheAckBack()
{
@@ -343,6 +360,21 @@ public class GrpcCentralTransportTests : IAsyncLifetime
}
/// <summary>A gRPC channel handler that throws (a refused connection) while its node is "down".</summary>
private sealed class LostReconcileResponseHandler(HttpMessageHandler inner, Func<bool> lose) : DelegatingHandler(inner)
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
var response = await base.SendAsync(request, ct);
if (!lose() || !request.RequestUri!.AbsolutePath.EndsWith("/ReconcileSite", StringComparison.Ordinal)) return response;
await response.Content.ReadAsByteArrayAsync(ct); // application has replied; only its response is lost
response.Dispose();
var failure = new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Version = new Version(2, 0), Content = new ByteArrayContent([]), RequestMessage = request };
failure.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/grpc");
failure.Headers.TryAddWithoutValidation("grpc-status", "14");
return failure;
}
}
private sealed class ToggleHandler : DelegatingHandler
{
private readonly Func<bool> _isUp;
@@ -382,6 +414,7 @@ public class GrpcCentralTransportTests : IAsyncLifetime
public TestServer Server { get; private set; } = null!;
public volatile bool IsUp = true;
public int SubmitCount => _counters.Submits;
public int ReconcileCount => Volatile.Read(ref _counters.Reconciles);
public static async Task<CentralNode> StartAsync(
ActorSystem system, string label, string site, string key, bool repliesToSubmit)
@@ -429,6 +462,7 @@ public class GrpcCentralTransportTests : IAsyncLifetime
private sealed class StubCounters
{
private int _submits;
public int Reconciles;
public int Submits => Volatile.Read(ref _submits);
public void IncrementSubmits() => Interlocked.Increment(ref _submits);
public volatile bool BlackHole;
@@ -447,6 +481,12 @@ public class GrpcCentralTransportTests : IAsyncLifetime
}
});
Receive<ReconcileSiteRequest>(_ =>
{
Interlocked.Increment(ref counters.Reconciles);
Sender.Tell(new ReconcileSiteResponse([], [], "http://central/"));
});
// Heartbeat lands here as a Tell (the failback probe); ignore it, no reply expected.
ReceiveAny(_ => { });
}
@@ -114,9 +114,9 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime
}
[Fact]
public async Task Unavailable_OnNodeA_FailsOverToNodeB_AndStaysSticky()
public async Task NotDispatched_OnNodeA_FailsOverToNodeB_AndStaysSticky()
{
_stubA.ThrowUnavailable = true;
_stubA.RejectBeforeDispatch = true;
var reply = await CallAsync();
@@ -135,12 +135,12 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime
[Fact]
public async Task Failback_ReturnsToPreferredNodeA_OncePreferredIsReachableAgain()
{
_stubA.ThrowUnavailable = true;
_stubA.RejectBeforeDispatch = true;
await CallAsync(); // flips to B
Assert.False(_provider.IsOnPreferredNode(SiteId));
// NodeA recovers; the failback probe reports it reachable.
_stubA.ThrowUnavailable = false;
_stubA.RejectBeforeDispatch = false;
_preferredReachable = true;
var back = await _provider.TryFailbackAsync(SiteId, CancellationToken.None);
@@ -156,7 +156,7 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime
public async Task DeadlineExceeded_OnNodeA_IsNotRetriedOnNodeB()
{
// A DeadlineExceeded is ambiguous (a WriteTag/Deploy/Failover may already have executed), so
// — unlike Unavailable — it must NOT fail over to B. Modelled by A returning the status
// it must NOT fail over to B. Modelled by A returning the status
// directly, isolating the retry-decision from TestServer's own timeout mechanics.
_stubA.StatusToThrow = StatusCode.DeadlineExceeded;
@@ -168,6 +168,19 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime
Assert.True(_provider.IsOnPreferredNode(SiteId), "a deadline must not flip stickiness");
}
[Fact]
public async Task AmbiguousUnavailable_DoesNotReplayLifecycleOnPeer()
{
_stubA.StatusToThrow = StatusCode.Unavailable;
var error = await Assert.ThrowsAsync<RpcException>(() => CallAsync());
Assert.Equal(StatusCode.Aborted, error.StatusCode);
Assert.Contains("OUTCOME_UNKNOWN", error.Status.Detail);
Assert.Equal(1, _stubA.LifecycleCalls);
Assert.Equal(0, _stubB.LifecycleCalls);
}
[Fact]
public async Task UnknownSite_Throws_SiteChannelUnavailable()
{
@@ -190,7 +203,7 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime
public string? LastAuthHeader { get; private set; }
public string? LastSiteHeader { get; private set; }
public bool DeadlineWasSet { get; private set; }
public volatile bool ThrowUnavailable;
public volatile bool RejectBeforeDispatch;
public StatusCode? StatusToThrow;
public override Task<LifecycleReply> ExecuteLifecycle(LifecycleRequest request, ServerCallContext context)
@@ -202,9 +215,9 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime
.FirstOrDefault(h => h.Key == ControlPlaneCredentials.SiteHeader)?.Value;
DeadlineWasSet = context.Deadline != DateTime.MaxValue;
if (ThrowUnavailable)
if (RejectBeforeDispatch)
{
throw new RpcException(new Status(StatusCode.Unavailable, "node down"));
throw GrpcRetrySafety.NotDispatched("node not ready before dispatch");
}
if (StatusToThrow is { } status)
{
@@ -0,0 +1,136 @@
using System.Net;
using System.Security.Claims;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
public class SessionActivityHttpIntegrationTests
{
[Fact]
public async Task PassivePing_RoleReplacementDoesNotRenewCookie_AndIdleExpires()
{
await using var fixture = await Fixture.CreateAsync();
await fixture.LoginAsync();
fixture.Clock.Advance(16);
using var alive = await fixture.SendAsync(HttpMethod.Get, "/auth/ping");
Assert.Equal(HttpStatusCode.OK, alive.StatusCode);
Assert.False(alive.Headers.Contains("Set-Cookie"));
fixture.Clock.Advance(15);
using var expired = await fixture.SendAsync(HttpMethod.Get, "/auth/ping");
Assert.Equal(HttpStatusCode.Unauthorized, expired.StatusCode);
Assert.False((await fixture.Circuit!.GetAuthoritativeStateAsync()).User.Identity!.IsAuthenticated);
}
[Fact]
public async Task AntiforgeryActivity_RenewsHttpAndExistingCircuit_ThenBothExpire()
{
await using var fixture = await Fixture.CreateAsync();
await fixture.LoginAsync();
var token = await fixture.TokenAsync();
fixture.Clock.Advance(20);
using var activity = await fixture.SendAsync(HttpMethod.Post, "/auth/activity", token);
Assert.Equal(HttpStatusCode.NoContent, activity.StatusCode);
Assert.True(activity.Headers.Contains("Set-Cookie"));
fixture.Clock.Advance(15);
using var alive = await fixture.SendAsync(HttpMethod.Get, "/auth/ping");
Assert.Equal(HttpStatusCode.OK, alive.StatusCode);
Assert.True((await fixture.Circuit!.GetAuthoritativeStateAsync()).User.Identity!.IsAuthenticated);
using var again = await fixture.SendAsync(HttpMethod.Post, "/auth/activity", token);
Assert.Equal(HttpStatusCode.NoContent, again.StatusCode);
fixture.Clock.Advance(31);
using var expired = await fixture.SendAsync(HttpMethod.Get, "/auth/ping");
Assert.Equal(HttpStatusCode.Unauthorized, expired.StatusCode);
Assert.False((await fixture.Circuit.GetAuthoritativeStateAsync()).User.Identity!.IsAuthenticated);
}
[Fact]
public async Task MissingAntiforgeryDoesNotRenew_AndExpiredActivityCannotRevive()
{
await using var fixture = await Fixture.CreateAsync();
await fixture.LoginAsync();
var token = await fixture.TokenAsync();
fixture.Clock.Advance(20);
using var rejected = await fixture.SendAsync(HttpMethod.Post, "/auth/activity");
Assert.Equal(HttpStatusCode.BadRequest, rejected.StatusCode);
Assert.False(rejected.Headers.Contains("Set-Cookie"));
fixture.Clock.Advance(11);
using var expired = await fixture.SendAsync(HttpMethod.Post, "/auth/activity", token);
Assert.Equal(HttpStatusCode.Unauthorized, expired.StatusCode);
Assert.False((await fixture.Circuit!.GetAuthoritativeStateAsync()).User.Identity!.IsAuthenticated);
}
private sealed class Fixture : IAsyncDisposable
{
private readonly WebApplication _app;
private HttpClient _client = null!;
private readonly Dictionary<string, string> _cookies = [];
public Clock Clock { get; }
public CookieAuthenticationStateProvider? Circuit { get; private set; }
private Fixture(WebApplication app, Clock clock) { _app = app; Clock = clock; }
public static async Task<Fixture> CreateAsync()
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
var clock = new Clock();
builder.Services.AddSingleton<TimeProvider>(clock);
builder.Services.AddSecurity();
builder.Services.Configure<SecurityOptions>(o => { o.RequireHttpsCookie = false; o.JwtSigningKey = new string('x', 64); });
builder.Services.AddSingleton<IGroupRoleMapper<string>, Mapper>();
builder.Services.AddOptions<CookieAuthenticationOptions>("Cookies").PostConfigure(o => o.TimeProvider = clock);
builder.Services.AddAntiforgery(o => o.HeaderName = "X-CSRF-TOKEN");
var app = builder.Build();
var fixture = new Fixture(app, clock);
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/test/login", async (HttpContext context) =>
{
var principal = SessionClaimBuilder.Build("alice", "Alice", ["group"],
new RoleMappingResult([Roles.Deployer], ["1"], false), clock.GetUtcNow());
fixture.Circuit = new CookieAuthenticationStateProvider(new HttpContextAccessor
{ HttpContext = new DefaultHttpContext { User = principal } }, app.Services.GetRequiredService<IServiceScopeFactory>());
await context.SignInAsync("Cookies", principal);
});
app.MapGet("/test/token", (HttpContext context, IAntiforgery antiforgery) =>
antiforgery.GetAndStoreTokens(context).RequestToken!);
app.MapAuthEndpoints();
await app.StartAsync();
fixture._client = app.GetTestClient();
return fixture;
}
public async Task LoginAsync() { using var response = await SendAsync(HttpMethod.Get, "/test/login"); response.EnsureSuccessStatusCode(); }
public async Task<string> TokenAsync() { using var response = await SendAsync(HttpMethod.Get, "/test/token"); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(); }
public async Task<HttpResponseMessage> SendAsync(HttpMethod method, string path, string? token = null)
{
using var request = new HttpRequestMessage(method, path);
if (_cookies.Count > 0) request.Headers.Add("Cookie", string.Join("; ", _cookies.Select(c => $"{c.Key}={c.Value}")));
if (token is not null) request.Headers.Add("X-CSRF-TOKEN", token);
var response = await _client.SendAsync(request);
if (response.Headers.TryGetValues("Set-Cookie", out var values))
foreach (var value in values) { var pair = value.Split(';')[0].Split('=', 2); _cookies[pair[0]] = pair[1]; }
return response;
}
public async ValueTask DisposeAsync() { _client.Dispose(); await _app.DisposeAsync(); }
}
private sealed class Clock : TimeProvider
{
public DateTimeOffset Now { get; set; } = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => Now;
public void Advance(int minutes) => Now = Now.AddMinutes(minutes);
}
private sealed class Mapper : IGroupRoleMapper<string>
{
public Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct)
=> Task.FromResult(new GroupRoleMapping<string>([Roles.Deployer], new RoleMappingResult([Roles.Deployer], ["1"], false)));
}
}
@@ -17,7 +17,7 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// </summary>
public class ApiMethodTraceAllCallsTests : TestKit, IDisposable
{
private readonly ServiceCollection _services = new();
private readonly ServiceCollection _services = new ServiceCollection().AddTestConfigurationTransaction();
private readonly IInboundApiRepository _apiRepo = Substitute.For<IInboundApiRepository>();
public ApiMethodTraceAllCallsTests()
@@ -0,0 +1,21 @@
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
namespace Microsoft.Extensions.DependencyInjection;
internal static class ConfigurationTransactionTestExtensions
{
public static ServiceCollection AddTestConfigurationTransaction(this ServiceCollection services)
{
services.AddSingleton<IConfigurationCommandTransaction, RepositoryTransaction>();
services.AddScoped<ZB.MOM.WW.ScadaBridge.TemplateEngine.Services.AreaService>();
return services;
}
private sealed class RepositoryTransaction(IServiceScopeFactory scopes) : IConfigurationCommandTransaction
{
public async Task<object?> ExecuteAsync(Func<IServiceProvider, Task<object?>> operation)
{
using var scope = scopes.CreateScope();
return await operation(scope.ServiceProvider);
}
}
}
@@ -42,7 +42,7 @@ public class ManagementActorTests : TestKit, IDisposable
_templateRepo = Substitute.For<ITemplateEngineRepository>();
_auditService = Substitute.For<IAuditService>();
_services = new ServiceCollection();
_services = new ServiceCollection().AddTestConfigurationTransaction();
_services.AddScoped(_ => _templateRepo);
_services.AddScoped(_ => _auditService);
_services.AddScoped<InstanceService>();
@@ -32,6 +32,12 @@ public class RequiredRoleMatrixTests
private static readonly Dictionary<string, string[]?> Expected = new(StringComparer.OrdinalIgnoreCase)
{
// ---- Administrator-only -----------------------------------------------------
["CreateSmtpConfig"] = [Roles.Administrator],
["CreateSmsConfig"] = [Roles.Administrator],
["RenameNotificationList"] = [Roles.Designer],
["AddNotificationRecipient"] = [Roles.Designer],
["DeleteNotificationRecipient"] = [Roles.Designer],
["DeleteInstanceAttributeOverride"] = [Roles.Deployer],
["CreateSite"] = [Roles.Administrator],
["UpdateSite"] = [Roles.Administrator],
["DeleteSite"] = [Roles.Administrator],
@@ -37,7 +37,7 @@ public class RoleMappingValidationTests : TestKit, IDisposable
_securityRepo = Substitute.For<ISecurityRepository>();
_auditService = Substitute.For<IAuditService>();
_services = new ServiceCollection();
_services = new ServiceCollection().AddTestConfigurationTransaction();
_services.AddScoped(_ => _securityRepo);
_services.AddScoped(_ => _auditService);
}
@@ -33,7 +33,7 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
_schemaRepo = Substitute.For<ISharedSchemaRepository>();
_auditService = Substitute.For<IAuditService>();
_services = new ServiceCollection();
_services = new ServiceCollection().AddTestConfigurationTransaction();
_services.AddScoped(_ => _schemaRepo);
_services.AddScoped(_ => _auditService);
}
@@ -26,7 +26,7 @@ public class SecretProjectionTests : TestKit, IDisposable
public SecretProjectionTests()
{
_auditService = Substitute.For<IAuditService>();
_services = new ServiceCollection();
_services = new ServiceCollection().AddTestConfigurationTransaction();
_services.AddScoped(_ => _auditService);
}
@@ -47,7 +47,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
_auditRepo = Substitute.For<IAuditLogRepository>();
_comms = new StubCommunicationService();
_services = new ServiceCollection();
_services = new ServiceCollection().AddTestConfigurationTransaction();
_services.AddScoped(_ => _siteRepo);
_services.AddScoped(_ => _securedWriteRepo);
_services.AddScoped(_ => _auditRepo);
@@ -25,7 +25,7 @@ public class TransportGateTests : TestKit, IDisposable
public TransportGateTests()
{
_importer = Substitute.For<IBundleImporter>();
_services = new ServiceCollection();
_services = new ServiceCollection().AddTestConfigurationTransaction();
_services.AddScoped(_ => _importer);
}
@@ -243,6 +243,19 @@ public class NotificationOutboxActorQueryTests : TestKit
Assert.Empty(response.Notifications);
}
[Theory]
[InlineData("site-1", true)]
[InlineData("site-other", false)]
public void StatusQuery_VerifiedSiteMustOwnStoredNotification(string verifiedSite, bool found)
{
var row = MakeNotification(lastError: "private delivery error");
_repository.GetByIdAsync(row.NotificationId, Arg.Any<CancellationToken>()).Returns(row);
CreateActor().Tell(new NotificationStatusQuery("scope", row.NotificationId, verifiedSite), TestActor);
var response = ExpectMsg<NotificationStatusResponse>();
Assert.Equal(found, response.Found);
if (!found) { Assert.Null(response.LastError); Assert.Empty(response.Status); }
}
[Fact]
public void StatusQuery_Found_RepliesWithRowDetail()
{
@@ -11,6 +11,25 @@ namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests;
/// </summary>
public class ScriptTrustValidatorTests
{
[Theory]
[InlineData("object o = new object(); System.Runtime.CompilerServices.Unsafe.As<object, string>(ref o);")]
[InlineData("System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(typeof(object));")]
[InlineData("new System.Runtime.Loader.AssemblyLoadContext(\"probe\");")]
[InlineData("using U = System.Runtime.CompilerServices.Unsafe; object o = new object(); U.As<object, string>(ref o);")]
[InlineData("using static System.Runtime.CompilerServices.RuntimeHelpers; GetUninitializedObject(typeof(object));")]
public void MG1_RejectsRuntimeEscapeApis(string code)
{
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
[Theory]
[InlineData("await System.Threading.Tasks.Task.Delay(1);")]
[InlineData("string Name([System.Runtime.CompilerServices.CallerMemberName] string name = \"\") => name;")]
public void MG1_AllowsCompilerSupport(string code)
{
Assert.Empty(ScriptTrustValidator.FindViolations(code));
}
// ---- Reject (non-empty violations) --------------------------------------
[Fact]
@@ -0,0 +1,66 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Roles;
namespace ZB.MOM.WW.ScadaBridge.Security.Tests;
public class PassiveSessionRegressionTests
{
private static readonly DateTimeOffset Start = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero);
[Fact]
public async Task PassivePing_RefreshesRolesWithoutAdvancingActivity_ThenExpires()
{
var clock = new Clock { Now = Start.AddMinutes(16) };
IGroupRoleMapper<string> mapper = new Mapper();
var services = new ServiceCollection().AddSingleton<TimeProvider>(clock).AddLogging();
services.AddSingleton(mapper);
services.AddSingleton<IAuthenticationService>(new Auth());
services.AddSingleton(Options.Create(new SecurityOptions()));
services.AddScoped<CookieSessionValidator>();
using var provider = services.BuildServiceProvider();
var principal = SessionClaimBuilder.Build("alice", "Alice", ["group"],
new RoleMappingResult([Roles.Deployer], ["1"], false), Start);
var ctx = Context(provider, principal, "/auth/ping");
await ServiceCollectionExtensions.OnValidatePrincipalAsync(ctx);
Assert.Equal(Start.ToString("o"), ctx.Principal!.FindFirst(JwtTokenService.LastActivityClaimType)!.Value);
Assert.Contains(Roles.Viewer, ctx.Principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value));
clock.Now = Start.AddMinutes(31);
var expired = Context(provider, ctx.Principal, "/auth/ping");
await ServiceCollectionExtensions.OnValidatePrincipalAsync(expired);
Assert.Null(expired.Principal);
}
private static CookieValidatePrincipalContext Context(IServiceProvider sp, ClaimsPrincipal principal, string path)
{
var http = new DefaultHttpContext { RequestServices = sp };
http.Request.Path = path;
return new CookieValidatePrincipalContext(http,
new AuthenticationScheme("Cookies", null, typeof(CookieAuthenticationHandler)),
new CookieAuthenticationOptions(), new AuthenticationTicket(principal, new AuthenticationProperties(), "Cookies"));
}
private sealed class Mapper : IGroupRoleMapper<string>
{
public Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct)
=> Task.FromResult(new GroupRoleMapping<string>([Roles.Viewer], new RoleMappingResult([Roles.Viewer], ["2"], false)));
}
private sealed class Auth : IAuthenticationService
{
public Task<AuthenticateResult> AuthenticateAsync(HttpContext c, string? s) => Task.FromResult(AuthenticateResult.NoResult());
public Task ChallengeAsync(HttpContext c, string? s, AuthenticationProperties? p) => Task.CompletedTask;
public Task ForbidAsync(HttpContext c, string? s, AuthenticationProperties? p) => Task.CompletedTask;
public Task SignInAsync(HttpContext c, string? s, ClaimsPrincipal u, AuthenticationProperties? p) => Task.CompletedTask;
public Task SignOutAsync(HttpContext c, string? s, AuthenticationProperties? p) => Task.CompletedTask;
}
private sealed class Clock : TimeProvider
{
public DateTimeOffset Now { get; set; }
public override DateTimeOffset GetUtcNow() => Now;
}
}
@@ -67,6 +67,17 @@ public class InstanceActorTests : TestKit, IDisposable
// ── M1.6: site event log `instance_lifecycle` category ──────────────────
[Fact]
public void Stop_ReleasesTagSubscriptionWithOwnerIdentity()
{
var (instance, dcl) = CreateInstanceWithDataSourcedAttribute(new SiteRuntimeOptions());
dcl.ExpectMsg<SubscribeTagsRequest>();
Sys.Stop(instance);
var request = dcl.ExpectMsg<UnsubscribeTagsRequest>();
Assert.Equal("conn-1", request.ConnectionName);
Assert.Equal(instance, dcl.LastSender);
}
[Fact]
public void InstanceActor_Start_EmitsInstanceLifecycleSiteEvent()
{
@@ -56,6 +56,49 @@ public class NativeAlarmActorTests : TestKit, IDisposable
Source(), "inst", instanceActor, dclManager, _storage, options,
NullLogger<NativeAlarmActor>.Instance, AlarmKind.NativeOpcUa, serviceProvider)));
[Fact]
public void Stop_ReleasesAlarmSubscriptionWithOwnerIdentity()
{
var dcl = CreateTestProbe();
var actor = Spawn(CreateTestProbe().Ref, dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
Sys.Stop(actor);
var request = dcl.ExpectMsg<UnsubscribeAlarmsRequest>();
Assert.Equal(Source().SourceReference, request.SourceReference);
Assert.Equal(actor, dcl.LastSender);
}
[Fact]
public void DuplicateSnapshotEnd_DoesNotClearCommittedAlarm()
{
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var actor = Spawn(instance.Ref, dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
var epoch = Guid.NewGuid();
NativeAlarmTransition Framed(AlarmTransitionKind kind)
{
var transition = Transition("T01.Hi", kind,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800));
var json = JsonSerializer.SerializeToNode(transition)!;
json["SnapshotId"] = JsonSerializer.SerializeToNode(epoch);
json["SnapshotSourceReference"] = Source().SourceReference;
return json.Deserialize<NativeAlarmTransition>()!;
}
// Append-only enum value for the new start marker; before the fix it is not handled.
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Framed((AlarmTransitionKind)7)));
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Framed(AlarmTransitionKind.Snapshot)));
var end = new NativeAlarmTransitionUpdate("Opc", Framed(AlarmTransitionKind.SnapshotComplete));
actor.Tell(end);
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
// Before the fix there is also an unwanted Active event for the unrecognized start.
// Fish out all initial active events before applying the duplicate marker.
actor.Tell(new SubscribeAlarmsResponse("barrier", "inst", true, null, DateTimeOffset.UtcNow));
instance.ReceiveWhile(TimeSpan.FromMilliseconds(100), msg => msg);
actor.Tell(end);
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
[Fact]
public void SubscribeOnStart_SendsRequestForSourceBinding()
{
@@ -0,0 +1,48 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
public class AttributeSchedulerRegressionTests : TestKit
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task SR1_SaturatedWorkersCompleteSynchronousAccessWithoutWatchdog(bool write)
{
const int workers = 2;
using var scheduler = new ScriptExecutionScheduler(workers);
var probe = CreateTestProbe();
var context = new ScriptRuntimeContext(probe.Ref, null!, 0, 10, TimeSpan.FromSeconds(3),
"Pump", NullLogger<ScriptRuntimeContext>.Instance);
var accessor = new AttributeAccessor(context, "");
var tasks = Enumerable.Range(0, workers).Select(_ => Task.Factory.StartNew(() =>
{
if (write) accessor["x"] = 42;
else Assert.Equal(42, accessor["x"]);
}, CancellationToken.None, TaskCreationOptions.None, scheduler)).ToArray();
// Hold both replies until every worker is blocked inside the synchronous accessor.
var replies = new List<(IActorRef Sender, object Response)>();
for (var i = 0; i < workers; i++)
{
if (write)
{
var request = probe.ExpectMsg<SetStaticAttributeCommand>();
replies.Add((probe.LastSender, new SetStaticAttributeResponse(request.CorrelationId,
"Pump", "x", true, null, DateTimeOffset.UtcNow)));
}
else
{
var request = probe.ExpectMsg<GetAttributeRequest>();
replies.Add((probe.LastSender, new GetAttributeResponse(request.CorrelationId,
"Pump", "x", 42, true, "Good", DateTimeOffset.UtcNow)));
}
}
foreach (var reply in replies) reply.Sender.Tell(reply.Response);
await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(2));
}
}
@@ -0,0 +1,55 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
public class AuditingDbTransactionTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task SR11_TransactionConnectionAndCommandReadbackRemainAudited(bool asynchronous)
{
var inner = new SqliteConnection("Data Source=:memory:");
await inner.OpenAsync();
using (var seed = inner.CreateCommand())
{
seed.CommandText = "CREATE TABLE t (value INTEGER); INSERT INTO t VALUES (0)";
seed.ExecuteNonQuery();
}
var writer = new Mock<IAuditWriter>();
writer.Setup(w => w.WriteAsync(It.IsAny<ZB.MOM.WW.Audit.AuditEvent>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
await using var connection = new AuditingDbConnection(inner, writer.Object, "db", "site", "instance",
"script", NullLogger.Instance, Guid.NewGuid());
await using var transaction = asynchronous
? await connection.BeginTransactionAsync()
: connection.BeginTransaction();
Assert.Same(connection, transaction.Connection);
using var command = transaction.Connection!.CreateCommand();
Assert.Same(connection, command.Connection);
command.Transaction = transaction;
Assert.Same(transaction, command.Transaction);
Assert.Same(connection, command.Transaction!.Connection);
command.CommandText = "UPDATE t SET value = 42";
Assert.Equal(1, asynchronous ? await command.ExecuteNonQueryAsync() : command.ExecuteNonQuery());
if (asynchronous) await transaction.CommitAsync(); else transaction.Commit();
writer.Verify(w => w.WriteAsync(It.IsAny<ZB.MOM.WW.Audit.AuditEvent>(), It.IsAny<CancellationToken>()), Times.Once);
using var check = inner.CreateCommand();
check.CommandText = "SELECT value FROM t";
Assert.Equal(42L, check.ExecuteScalar());
}
[Fact]
public void SR11_CommandConnectionCannotExposeProvider()
{
using var inner = new SqliteConnection("Data Source=:memory:");
using var connection = new AuditingDbConnection(inner, Mock.Of<IAuditWriter>(), "db", "site", "instance",
"script", NullLogger.Instance, Guid.NewGuid());
using var command = connection.CreateCommand();
Assert.Same(connection, command.Connection);
}
}