merge(r2): PLAN-R2-07 UI/Management/Security

This commit is contained in:
Joseph Doherty
2026-07-13 11:08:10 -04:00
26 changed files with 852 additions and 92 deletions
@@ -208,6 +208,100 @@ public class AlarmSummaryRenderTests : BunitContext
Assert.True(_liveCache.DisposeCount >= 1);
}
// ── arch-review R2 N4: stale-site poll guard ───────────────────────────────
[Fact]
public void InFlightPollForPreviousSite_DoesNotOverwriteNewSitesRows()
{
// Site 1's fan-out hangs on a TCS; site 2 answers immediately (arch-review R2 N4).
var site1Tcs = new TaskCompletionSource<AlarmSummaryResult>();
_summary.GetSiteAlarmsAsync(1, Arg.Any<CancellationToken>()).Returns(site1Tcs.Task);
_summary.GetSiteAlarmsAsync(2, Arg.Any<CancellationToken>()).Returns(Task.FromResult(
new AlarmSummaryResult(
new List<AlarmSummaryRow>
{
new("Site2Instance", new AlarmStateChanged("Site2Instance", "S2-alarm", AlarmState.Active, 100, DateTimeOffset.UtcNow)),
},
Array.Empty<string>())));
var cut = Render<AlarmSummaryPage>();
cut.Find("[data-test='alarm-summary-site']").Change("1"); // poll in flight, hung
cut.Find("[data-test='alarm-summary-site']").Change("2"); // site 2 renders
cut.WaitForAssertion(() => Assert.Equal("Site2Instance", FirstRowInstance(cut)));
// The stale site-1 fan-out now completes — it must be dropped, not rendered.
site1Tcs.SetResult(new AlarmSummaryResult(
new List<AlarmSummaryRow>
{
new("StaleSite1", new AlarmStateChanged("StaleSite1", "S1-alarm", AlarmState.Active, 999, DateTimeOffset.UtcNow)),
},
new[] { "StaleNotReporting" }));
cut.WaitForAssertion(() =>
{
Assert.Equal("Site2Instance", FirstRowInstance(cut));
Assert.DoesNotContain("StaleNotReporting", cut.Markup);
});
}
// ── arch-review R2 N5: while live, the poll updates only _notReporting ──────
[Fact]
public void PollWhileLive_UpdatesNotReportingOnly_DoesNotRegressLiveRows()
{
// Poll snapshot = Zeta/Alpha (constructor stub) + a not-reporting instance.
_summary.GetSiteAlarmsAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => Task.FromResult(new AlarmSummaryResult(
new List<AlarmSummaryRow>
{
new("Zeta", new AlarmStateChanged("Zeta", "Z-alarm", AlarmState.Active, 900, DateTimeOffset.UtcNow)),
},
new[] { "OfflineInstance" })));
var cut = RenderWithSiteSelected();
// Go live with a fresher delta: Gamma only (arch-review R2 N5).
_liveCache.PushAlarms(new List<AlarmStateChanged>
{
new("Gamma", "G-alarm", AlarmState.Active, 300, DateTimeOffset.UtcNow),
});
cut.WaitForAssertion(() => Assert.Equal("Gamma", FirstRowInstance(cut)));
// A poll now completes: it owns _notReporting but must NOT rebuild the rows.
cut.Find("[data-test='alarm-summary-refresh']").Click();
cut.WaitForAssertion(() =>
{
Assert.Contains("OfflineInstance", cut.Markup); // notReporting refreshed
Assert.Equal("Gamma", FirstRowInstance(cut)); // live rows NOT regressed
Assert.Single(cut.FindAll("tr[data-test='alarm-summary-row']"));
});
}
// ── arch-review R2 N7: disposal guard on the live callback ─────────────────
[Fact]
public void LiveCallbackRacingDispose_IsDropped_NoRebuildNoThrow()
{
var cut = RenderWithSiteSelected();
_liveCache.PushAlarms(new List<AlarmStateChanged>
{
new("Gamma", "G-alarm", AlarmState.Active, 300, DateTimeOffset.UtcNow),
});
cut.WaitForAssertion(() => Assert.Equal("Gamma", FirstRowInstance(cut)));
// Snapshot the callback BEFORE disposal — this models a delta thread that
// already read the delegate when Dispose ran (arch-review R2 N7).
var inFlight = _liveCache.CapturedCallback!;
cut.Instance.Dispose();
_liveCache.PushAlarms(new List<AlarmStateChanged>()); // mutate the fake's snapshot
var ex = Record.Exception(inFlight);
Assert.Null(ex);
// The disposed page must not have re-applied the (now empty) live snapshot.
Assert.Equal("Gamma", FirstRowInstance(cut));
}
/// <summary>
/// Controllable in-memory fake of <see cref="ISiteAlarmLiveCache"/>: records
/// subscribe/dispose counts and lets a test push a live snapshot (which flips
@@ -223,6 +317,7 @@ public class AlarmSummaryRenderTests : BunitContext
public int SubscribeCount { get; private set; }
public int DisposeCount { get; private set; }
public int? LastSubscribedSite { get; private set; }
public Action? CapturedCallback => _onChanged;
public IDisposable Subscribe(int siteId, Action onChanged)
{
@@ -302,4 +302,40 @@ public class SiteAlarmLiveCacheServiceTests : TestKit
AwaitCondition(() => service.IsLive(3), TimeSpan.FromSeconds(5));
Assert.True(factory.GetOrCreateCount >= 1); // a client was created for the single endpoint
}
// ── R2 T10: deathwatch un-sticks IsLive on aggregator death (N6) ──
[Fact]
public async Task Aggregator_Death_Resets_IsLive_And_Clears_The_Snapshot()
{
var service = CreateService(TimeSpan.FromMinutes(5), out _);
using var sub = service.Subscribe(SiteId, () => { });
AwaitAssert(() => Assert.True(service.IsLive(SiteId)), TimeSpan.FromSeconds(10));
// Kill the aggregator out from under the service (crash-death, NOT a linger stop).
var aggregator = await Sys.ActorSelection($"/user/site-alarm-aggregator-{SiteId}-*")
.ResolveOne(TimeSpan.FromSeconds(5));
Sys.Stop(aggregator);
AwaitAssert(() =>
{
Assert.False(service.IsLive(SiteId)); // sticky no more (R2 N6)
Assert.Empty(service.GetCurrentAlarms(SiteId)); // frozen snapshot never grafted
}, TimeSpan.FromSeconds(10));
}
[Fact]
public void Linger_Stop_Does_Not_Resurrect_The_Aggregator()
{
// Last viewer leaves, linger fires, entry removed: the deathwatch on the
// deliberately-stopped actor must be a no-op (entry gone), not a restart.
var service = CreateService(TimeSpan.FromMilliseconds(50), out var factory);
var sub = service.Subscribe(SiteId, () => { });
AwaitAssert(() => Assert.True(service.IsLive(SiteId)), TimeSpan.FromSeconds(10));
sub.Dispose();
AwaitAssert(() => Assert.False(service.IsLive(SiteId)), TimeSpan.FromSeconds(10));
var startsAfterStop = factory.GetOrCreateCount;
Thread.Sleep(300);
Assert.Equal(startsAfterStop, factory.GetOrCreateCount); // no self-heal restart fired
}
}
@@ -164,6 +164,72 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(0, await repo.CountAsync(status: "Executed", siteId: siteId));
}
[SkippableFact]
public async Task QueryAsync_PermittedSiteIds_ReturnsOnlyPermittedRows()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Distinct per-run site identifiers stand in for "SITE1"/"SITE2"/"SITE3" so the
// permitted-set filter is asserted deterministically against a shared MSSQL.
var site1 = NewSiteId();
var site2 = NewSiteId();
var site3 = NewSiteId();
await using var context = CreateContext();
var repo = new SecuredWriteRepository(context);
await repo.AddAsync(NewRow(site1, status: "Pending"));
await repo.AddAsync(NewRow(site1, status: "Pending"));
await repo.AddAsync(NewRow(site2, status: "Pending"));
await repo.AddAsync(NewRow(site3, status: "Pending"));
var rows = await repo.QueryAsync(status: null, siteId: null, skip: 0, take: 100,
permittedSiteIds: new[] { site1, site3 });
Assert.Equal(3, rows.Count);
Assert.DoesNotContain(rows, r => r.SiteId == site2);
}
[SkippableFact]
public async Task CountAsync_PermittedSiteIds_CountsOnlyPermittedRows()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var site1 = NewSiteId();
var site2 = NewSiteId();
var site3 = NewSiteId();
await using var context = CreateContext();
var repo = new SecuredWriteRepository(context);
await repo.AddAsync(NewRow(site1, status: "Pending"));
await repo.AddAsync(NewRow(site1, status: "Pending"));
await repo.AddAsync(NewRow(site2, status: "Pending"));
await repo.AddAsync(NewRow(site3, status: "Pending"));
var count = await repo.CountAsync(status: null, siteId: null,
permittedSiteIds: new[] { site1, site3 });
Assert.Equal(3, count);
}
[SkippableFact]
public async Task QueryAsync_NullPermittedSiteIds_IsUnfiltered_BackCompat()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// 4 rows on a single unique site; null permittedSiteIds must not filter them
// out — exactly today's behavior.
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new SecuredWriteRepository(context);
await repo.AddAsync(NewRow(siteId, status: "Pending"));
await repo.AddAsync(NewRow(siteId, status: "Pending"));
await repo.AddAsync(NewRow(siteId, status: "Executed"));
await repo.AddAsync(NewRow(siteId, status: "Rejected"));
var rows = await repo.QueryAsync(status: null, siteId: siteId, skip: 0, take: 100,
permittedSiteIds: null);
Assert.Equal(4, rows.Count);
}
// --- helpers ------------------------------------------------------------
private ScadaBridgeDbContext CreateContext()
@@ -42,4 +42,47 @@ public class ConfigSecretScrubberTests
Assert.Contains("\"real\"", merged);
Assert.DoesNotContain(ConfigSecretScrubber.Sentinel, merged);
}
// ── arch-review R2 N9: frozen scrubber fragment coverage ───────────────────
// Frozen non-secret allowlist (arch-review R2 N9), mirroring RequiredRoleMatrixTests:
// every string-typed property on the DataConnections config types must EITHER match
// the scrubber's secret fragments (it gets elided) OR be listed here after a
// deliberate "this is not a secret" decision. A new string property with neither
// fails EveryConfigStringProperty_IsScrubbedOrDeliberatelyNonSecret, so a future
// "SigningKey"/"Pin" cannot silently leak through List/Get/audit.
private static readonly Dictionary<Type, string[]> KnownNonSecretStringProperties = new()
{
[typeof(ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections.OpcUaEndpointConfig)] = ["EndpointUrl", "SubscriptionDisplayName"],
[typeof(ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections.OpcUaUserIdentityConfig)] = ["Username", "CertificatePath"],
[typeof(ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections.MxGatewayEndpointConfig)] = ["Endpoint", "ClientName", "CaFile", "ServerName"],
[typeof(ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections.OpcUaHeartbeatConfig)] = ["TagPath"],
[typeof(ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections.OpcUaDeadbandConfig)] = [],
};
[Fact]
public void EveryConfigStringProperty_IsScrubbedOrDeliberatelyNonSecret()
{
foreach (var (type, allowlist) in KnownNonSecretStringProperties)
{
var stringProps = type.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
.Where(p => p.PropertyType == typeof(string))
.Select(p => p.Name);
foreach (var name in stringProps)
{
var scrubbed = ConfigSecretScrubber.IsSecretName(name);
var allowlisted = allowlist.Contains(name);
Assert.True(scrubbed ^ allowlisted,
$"{type.Name}.{name}: must be EITHER fragment-scrubbed OR explicitly " +
"allowlisted as non-secret (and never both) — see arch-review R2 N9.");
}
}
}
[Theory]
[InlineData("Password")]
[InlineData("CertificatePassword")]
[InlineData("ApiKey")]
public void KnownSecretProperties_MatchTheFragmentList(string name)
=> Assert.True(ConfigSecretScrubber.IsSecretName(name));
}
@@ -1,4 +1,19 @@
using System.Net;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Connections.Features;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.Security.Auth;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
@@ -63,4 +78,83 @@ public class DebugStreamHubTests
Assert.True(allowed);
}
// --- OnConnectedAsync delegates to ManagementAuthenticator (arch-review R2 N1) ------------
private readonly ILdapAuthService _ldap = Substitute.For<ILdapAuthService>();
private readonly ServiceProvider _provider;
private readonly LoginThrottle _throttle;
public DebugStreamHubTests()
{
var services = new ServiceCollection();
services.AddSingleton<IOptions<AuthDisableLoginOptions>>(
Options.Create(new AuthDisableLoginOptions { DisableLogin = false }));
services.AddSingleton<IOptions<SecurityOptions>>(Options.Create(new SecurityOptions()));
services.AddSingleton(TimeProvider.System);
services.AddSingleton<LoginThrottle>();
services.AddSingleton(_ldap);
services.AddLogging();
_provider = services.BuildServiceProvider();
_throttle = _provider.GetRequiredService<LoginThrottle>();
}
private async Task<TestHubCallerContext> ConnectAsync(string username, string password)
{
var http = new DefaultHttpContext { RequestServices = _provider };
var creds = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
http.Request.Headers.Authorization = $"Basic {creds}";
http.Connection.RemoteIpAddress = IPAddress.Parse("10.0.0.1");
var debugStreamService = new DebugStreamService(
new CommunicationService(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance),
new ServiceCollection().BuildServiceProvider(),
new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance),
NullLogger<DebugStreamService>.Instance);
var hubContext = Substitute.For<IHubContext<DebugStreamHub>>();
var hub = new DebugStreamHub(debugStreamService, hubContext, NullLogger<DebugStreamHub>.Instance);
var ctx = new TestHubCallerContext(http);
hub.Context = ctx;
await hub.OnConnectedAsync();
return ctx;
}
[Fact]
public async Task OnConnectedAsync_FailedBinds_FeedTheSharedLoginThrottle()
{
// 5 failed hub connects for alice@10.0.0.1 must arm the same lockout the
// /management and /auth surfaces consult (arch-review R2 N1).
_ldap.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
for (var i = 0; i < 5; i++) await ConnectAsync("alice", "wrong"); // each Aborted
Assert.True(_throttle.IsLockedOut("alice", "10.0.0.1"));
}
[Fact]
public async Task OnConnectedAsync_LockedOutKey_AbortsWithoutContactingLdap()
{
for (var i = 0; i < 5; i++) _throttle.RecordFailure("alice", "10.0.0.1");
_ldap.ClearReceivedCalls();
var ctx = await ConnectAsync("alice", "whatever");
Assert.True(ctx.Aborted);
await _ldap.DidNotReceiveWithAnyArgs().AuthenticateAsync(default!, default!, default);
}
private sealed class TestHubCallerContext : HubCallerContext
{
private readonly FeatureCollection _features = new();
public TestHubCallerContext(HttpContext http) =>
_features.Set<IHttpContextFeature>(new TestHttpContextFeature { HttpContext = http });
public bool Aborted { get; private set; }
public override string ConnectionId => "test-conn";
public override string? UserIdentifier => null;
public override System.Security.Claims.ClaimsPrincipal? User => null;
public override IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
public override IFeatureCollection Features => _features;
public override CancellationToken ConnectionAborted => CancellationToken.None;
public override void Abort() => Aborted = true;
private sealed class TestHttpContextFeature : IHttpContextFeature { public HttpContext? HttpContext { get; set; } }
}
}
@@ -145,7 +145,7 @@ public class ManagementActorTests : TestKit, IDisposable
var securedWriteRepo = Substitute.For<ISecuredWriteRepository>();
securedWriteRepo.QueryAsync(
Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<int>(), Arg.Any<int>(),
Arg.Any<CancellationToken>())
Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<PendingSecuredWrite>>(
Array.Empty<PendingSecuredWrite>()));
_services.AddScoped(_ => securedWriteRepo);
@@ -151,6 +151,9 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
new(new AuthenticatedUser(username, username, roles, Array.Empty<string>()),
command, Guid.NewGuid().ToString("N"));
private static ManagementEnvelope ScopedEnvelope(object command, string username, string[] permittedSiteIds, params string[] roles) =>
new(new AuthenticatedUser(username, username, roles, permittedSiteIds), command, Guid.NewGuid().ToString("N"));
void IDisposable.Dispose() => Shutdown();
/// <summary>Wires a site whose single connection uses the given protocol.</summary>
@@ -346,7 +349,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
[Fact]
public void List_FiltersByStatusAndSite()
{
_securedWriteRepo.QueryAsync("Pending", "SITE1", 0, 200, Arg.Any<CancellationToken>())
_securedWriteRepo.QueryAsync("Pending", "SITE1", 0, 200, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
.Returns(new List<PendingSecuredWrite>
{
new()
@@ -369,13 +372,13 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Contains("\"id\":3", response.JsonData);
Assert.Contains("alice", response.JsonData);
_securedWriteRepo.Received(1).QueryAsync("Pending", "SITE1", 0, 200, Arg.Any<CancellationToken>());
_securedWriteRepo.Received(1).QueryAsync("Pending", "SITE1", 0, 200, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>());
}
[Fact]
public void List_NoFilters_PassesNullsToRepository()
{
_securedWriteRepo.QueryAsync(null, null, 0, 200, Arg.Any<CancellationToken>())
_securedWriteRepo.QueryAsync(null, null, 0, 200, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
.Returns(new List<PendingSecuredWrite>());
var actor = CreateActor();
@@ -385,7 +388,49 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_securedWriteRepo.Received(1).QueryAsync(null, null, 0, 200, Arg.Any<CancellationToken>());
_securedWriteRepo.Received(1).QueryAsync(null, null, 0, 200, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>());
}
[Fact]
public void List_SiteScopedReader_ExplicitOutOfScopeSiteFilter_Unauthorized()
{
SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new ListSecuredWritesCommand(null, "SITE1"), "bob", ["3"], "Verifier"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
[Fact]
public void List_SiteScopedReader_Unfiltered_QueriesOnlyPermittedIdentifiers()
{
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site>
{
new("Site1", "SITE1") { Id = 1 },
new("Site3", "SITE3") { Id = 3 },
});
_securedWriteRepo.QueryAsync(null, null, 0, 200,
Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
.Returns(new List<PendingSecuredWrite>());
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new ListSecuredWritesCommand(null, null), "bob", ["3"], "Verifier"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_securedWriteRepo.Received(1).QueryAsync(null, null, 0, 200,
Arg.Is<IReadOnlyCollection<string>?>(s => s != null && s.Single() == "SITE3"),
Arg.Any<CancellationToken>());
}
[Fact]
public void List_SystemWideReader_PassesNullPermittedFilter()
{
_securedWriteRepo.QueryAsync(null, null, 0, 200,
Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
.Returns(new List<PendingSecuredWrite>());
var actor = CreateActor();
actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), "carol", "Operator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_securedWriteRepo.Received(1).QueryAsync(null, null, 0, 200,
Arg.Is<IReadOnlyCollection<string>?>(s => s == null),
Arg.Any<CancellationToken>());
}
// ------------------------------------------------------------------------
@@ -620,16 +665,16 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
[Fact]
public void List_WithSkipTake_ForwardsPagingAndReturnsTotalCount()
{
_securedWriteRepo.QueryAsync(null, null, 10, 25, Arg.Any<CancellationToken>())
_securedWriteRepo.QueryAsync(null, null, 10, 25, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
.Returns(new List<PendingSecuredWrite>());
_securedWriteRepo.CountAsync(null, null, Arg.Any<CancellationToken>()).Returns(87);
_securedWriteRepo.CountAsync(null, null, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>()).Returns(87);
var actor = CreateActor();
actor.Tell(Envelope(new ListSecuredWritesCommand(null, null, Skip: 10, Take: 25), "carol", "Operator"));
var resp = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_securedWriteRepo.Received(1).QueryAsync(null, null, 10, 25, Arg.Any<CancellationToken>());
_securedWriteRepo.Received(1).CountAsync(null, null, Arg.Any<CancellationToken>());
_securedWriteRepo.Received(1).QueryAsync(null, null, 10, 25, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>());
_securedWriteRepo.Received(1).CountAsync(null, null, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>());
Assert.Contains("\"totalCount\":87", resp.JsonData);
}
@@ -652,7 +697,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
ValueJson = "false", ValueType = "Boolean", Status = "Pending",
OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow.AddHours(-25) // TTL default 24h
};
_securedWriteRepo.QueryAsync(null, null, 0, 200, Arg.Any<CancellationToken>())
_securedWriteRepo.QueryAsync(null, null, 0, 200, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
.Returns(new List<PendingSecuredWrite> { fresh, stale });
_securedWriteRepo.TryMarkExpiredAsync(2, Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(true);
@@ -899,4 +944,86 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
var execute = SingleOfKind(captured, AuditKind.SecuredWriteExecute);
Assert.Equal(ZB.MOM.WW.Audit.AuditOutcome.Failure, execute.Outcome);
}
// ------------------------------------------------------------------------
// Site scope on submit / approve / reject (arch-review R2 N3)
// ------------------------------------------------------------------------
[Fact]
public void Submit_SiteScopedOperator_OutOfScopeSite_Unauthorized_NoRowInserted()
{
SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
var actor = CreateActor();
actor.Tell(ScopedEnvelope(
new SubmitSecuredWriteCommand("SITE1", "Gw1", "Tag.A", "true", "Boolean", "go"),
"alice", ["3"], "Operator"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
_securedWriteRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default);
}
[Fact]
public void Submit_SiteScopedOperator_InScopeSite_Succeeds()
{
SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
var actor = CreateActor();
actor.Tell(ScopedEnvelope(
new SubmitSecuredWriteCommand("SITE1", "Gw1", "Tag.A", "true", "Boolean", "go"),
"alice", ["1"], "Operator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
}
[Fact]
public void Approve_SiteScopedVerifier_OutOfScopeSite_Unauthorized_NeverRelays()
{
SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
_securedWriteRepo.GetAsync(42, Arg.Any<CancellationToken>()).Returns(new PendingSecuredWrite
{
Id = 42, SiteId = "SITE1", ConnectionName = "Gw1", TagPath = "Tag.A",
ValueJson = "true", ValueType = "Boolean", Status = "Pending",
OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow,
});
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new ApproveSecuredWriteCommand(42, "ok"), "bob", ["3"], "Verifier"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
_securedWriteRepo.DidNotReceiveWithAnyArgs().TryMarkApprovedAsync(default, default!, default, default, default);
Assert.Equal(0, _comms.CallCount); // the device write is NEVER relayed
}
[Fact]
public void Reject_SiteScopedVerifier_OutOfScopeSite_Unauthorized()
{
SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
_securedWriteRepo.GetAsync(42, Arg.Any<CancellationToken>()).Returns(new PendingSecuredWrite
{
Id = 42, SiteId = "SITE1", ConnectionName = "Gw1", TagPath = "Tag.A",
ValueJson = "true", ValueType = "Boolean", Status = "Pending",
OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow,
});
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new RejectSecuredWriteCommand(42, "no"), "bob", ["3"], "Verifier"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
_securedWriteRepo.DidNotReceiveWithAnyArgs().UpdateAsync(default!, default);
}
[Fact]
public void Approve_ScopedAdministrator_BypassesSiteScope()
{
// A scoped Verifier who is ALSO an Administrator bypasses site scope
// (EnforceSiteScope admin bypass, ManagementActor.cs:499) — the approve
// proceeds past the scope gate rather than returning ManagementUnauthorized.
SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
_securedWriteRepo.GetAsync(42, Arg.Any<CancellationToken>()).Returns(new PendingSecuredWrite
{
Id = 42, SiteId = "SITE1", ConnectionName = "Gw1", TagPath = "Tag.A",
ValueJson = "true", ValueType = "Boolean", Status = "Pending",
OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow,
});
_securedWriteRepo.TryMarkApprovedAsync(
42, Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(true);
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new ApproveSecuredWriteCommand(42, "ok"), "carol", ["3"], "Verifier", "Administrator"));
var response = ExpectMsg<object>(TimeSpan.FromSeconds(5));
Assert.IsNotType<ManagementUnauthorized>(response);
}
}
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.HttpOverrides;
using ZB.MOM.WW.ScadaBridge.Security;
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.Security.Tests;
public class ForwardedHeadersSetupTests
{
[Fact]
public void Build_Disabled_ReturnsNull()
=> Assert.Null(ForwardedHeadersSetup.Build(new ForwardedHeadersSecurityOptions { Enabled = false }));
[Fact]
public void Build_Enabled_ParsesProxiesAndNetworks_AndClearsDefaults()
{
var built = ForwardedHeadersSetup.Build(new ForwardedHeadersSecurityOptions
{
Enabled = true,
KnownProxies = ["10.5.0.9"],
KnownNetworks = ["172.16.0.0/12"],
})!;
Assert.Equal(ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, built.ForwardedHeaders);
Assert.Equal(1, built.ForwardLimit);
Assert.Single(built.KnownProxies); // ONLY the configured proxy —
Assert.DoesNotContain(System.Net.IPAddress.IPv6Loopback, // the loopback default is cleared
built.KnownProxies);
Assert.Single(built.KnownIPNetworks); // net10 successor to the deprecated KnownNetworks
}
[Theory]
[InlineData("not-an-ip", "172.16.0.0/12")]
[InlineData("10.5.0.9", "not-a-cidr")]
public void Build_MalformedEntry_ThrowsAtStartup(string proxy, string network)
=> Assert.ThrowsAny<Exception>(() => ForwardedHeadersSetup.Build(
new ForwardedHeadersSecurityOptions { Enabled = true, KnownProxies = [proxy], KnownNetworks = [network] }));
}
@@ -25,6 +25,12 @@ public class LoginThrottleTests
private static LoginThrottle Create(TimeProvider clock, SecurityOptions? options = null) =>
new(clock, Options.Create(options ?? new SecurityOptions()));
private static (LoginThrottle throttle, TestTimeProvider clock) Create()
{
var clock = new TestTimeProvider(Start);
return (Create(clock), clock);
}
[Fact]
public void FiveFailuresInWindow_LocksOut_ThenWindowExpiryUnlocks()
{
@@ -34,7 +40,7 @@ public class LoginThrottleTests
for (var i = 0; i < 5; i++) throttle.RecordFailure("alice", "10.0.0.1");
Assert.True(throttle.IsLockedOut("alice", "10.0.0.1"));
Assert.False(throttle.IsLockedOut("alice", "10.0.0.2")); // per username+IP key
Assert.False(throttle.IsLockedOut("alice", "10.0.0.2")); // per username+IP key — real isolation in production requires the trusted-proxy ForwardedHeaders config (R2 N2); without it all clients share the proxy IP
Assert.False(throttle.IsLockedOut("bob", "10.0.0.1"));
clock.Advance(TimeSpan.FromMinutes(6)); // lockout window passed
@@ -110,4 +116,29 @@ public class LoginThrottleTests
Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));
}
// ── arch-review R2 N8: amortized prune ─────────────────────────────────────
[Fact]
public void KeySpray_MapStaysBounded_AtTheHardCap()
{
var (throttle, _) = Create(); // existing helper: throttle over a fake clock
// Spray far past the cap: the over-cap eviction must run on EVERY write once
// the cap is exceeded, regardless of the amortized cadence (arch-review R2 N8).
for (var i = 0; i < LoginThrottle.MaxTrackedKeys + 500; i++)
throttle.RecordFailure($"user{i}", "10.0.0.1");
Assert.True(throttle.TrackedKeyCount <= LoginThrottle.MaxTrackedKeys);
}
[Fact]
public void ExpiredEntries_AreEventuallySwept_OnTheAmortizedCadence()
{
var (throttle, clock) = Create();
throttle.RecordFailure("stale", "10.0.0.1");
clock.Advance(TimeSpan.FromMinutes(10)); // window fully expired
for (var i = 0; i < LoginThrottle.PruneEveryNFailures; i++)
throttle.RecordFailure($"fresh{i}", "10.0.0.1"); // cadence tick fires within these
Assert.False(throttle.IsLockedOut("stale", "10.0.0.1"));
Assert.True(throttle.TrackedKeyCount <= LoginThrottle.PruneEveryNFailures); // "stale" swept
}
}