Resolve all high-severity architecture review findings
This commit is contained in:
+40
@@ -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()
|
||||
{
|
||||
|
||||
+66
@@ -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?>>>());
|
||||
}
|
||||
}
|
||||
+99
@@ -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
|
||||
|
||||
+1
@@ -61,6 +61,7 @@ public class InstanceConfigureAuditDrillinTests : BunitContext
|
||||
Services.AddSingleton<AuthenticationStateProvider>(authProvider);
|
||||
Services.AddSingleton(new SiteScopeService(authProvider));
|
||||
Services.AddAuthorizationCore();
|
||||
Services.AddTestConfigurationMutations();
|
||||
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -52,6 +52,7 @@ public class InstanceConfigureBindingBehaviourTests : BunitContext
|
||||
Services.AddSingleton<AuthenticationStateProvider>(authProvider);
|
||||
Services.AddSingleton(new SiteScopeService(authProvider));
|
||||
Services.AddAuthorizationCore();
|
||||
Services.AddTestConfigurationMutations();
|
||||
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -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);
|
||||
}
|
||||
|
||||
+1
@@ -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;
|
||||
}
|
||||
|
||||
+1
@@ -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;
|
||||
}
|
||||
|
||||
+1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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>
|
||||
|
||||
+4
-4
@@ -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)
|
||||
|
||||
+1
@@ -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]
|
||||
|
||||
+1
@@ -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]
|
||||
|
||||
+1
@@ -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);
|
||||
}
|
||||
}
|
||||
+55
@@ -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()
|
||||
{
|
||||
|
||||
+21
-1
@@ -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,
|
||||
|
||||
+247
@@ -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));
|
||||
}
|
||||
}
|
||||
+24
@@ -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()
|
||||
{
|
||||
|
||||
+32
@@ -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 & Conditions (no ConditionRefresh snapshot arrives). The OPC PLC
|
||||
/// simulator does not reliably expose A&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()
|
||||
|
||||
+21
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+13
@@ -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()
|
||||
{
|
||||
|
||||
+48
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user