merge(r2): PLAN-R2-07 UI/Management/Security
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+66
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user