153609ef8a
The #96 site-psk-invalidation topic was the first cross-node invalidation broadcast. It is generalised into ONE central DistributedPubSub topic, central-cache-invalidation, carrying typed messages (Commons ICentralCacheInvalidation: SitePskInvalidated, RoleMappingsInvalidated, ExternalDefinitionsInvalidated, InboundApiMethodInvalidated) in an envelope stamped with the publisher's process-unique origin id. - Host: CentralCacheInvalidationBus (ICentralCacheInvalidationPublisher, no-op until the mediator is set, never throws) and CentralCacheInvalidationSubscriber, which skips its own node's envelopes and hands the peer's to every registered ICentralCacheInvalidationHandler (a failing handler does not stop the rest). Replaces SitePskInvalidationSubscriber. - Migration of #96: SitePskRotationInvalidator publishes through the bus and is the PSK handler; SitePskInvalidated keeps its type name and is also published bare on site-psk-invalidation, which the subscriber still listens to, so a mixed-version central pair keeps exchanging PSK invalidations. Remove after one release. - Audit D1: RoleMappingCache is a handler; ManagementActor's post-commit role-mapping invalidation also publishes RoleMappingsInvalidated, so a revocation is immediate on the peer instead of after RoleMappingCacheSeconds. - Audit D3 / #83 (peer half): ExternalDefinitionLookupCache is a handler; the 9 ManagementActor handlers and the Transport bundle importer publish ExternalDefinitionsInvalidated after commit. (#83's local import half was already in.) TTLs remain the fallback for a lost broadcast or a direct SQL edit. Tests: bus and subscriber unit tests (origin filter, legacy topic), peer-node tests through the real caches in ManagementService.Tests, importer publish, composition root, and a real two-node cluster from the production HOCON (CentralCacheInvalidationTwoNodeTests) proving every message type crosses with the production serializer. Docs: Host, Communication, Security, ManagementService, ESG, and the #85 cache audit. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
135 lines
5.8 KiB
C#
135 lines
5.8 KiB
C#
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
|
|
|
/// <summary>
|
|
/// Lane K review M1: the process-wide <see cref="RoleMappingCache"/> served a deleted or
|
|
/// changed LDAP-group mapping for up to its TTL on the node that made the change. Every
|
|
/// role-mapping / scope-rule mutation now invalidates it once the change is committed.
|
|
/// </summary>
|
|
public class RoleMappingCacheInvalidationTests : TestKit
|
|
{
|
|
private readonly ISecurityRepository _repo = Substitute.For<ISecurityRepository>();
|
|
private readonly RoleMappingCache _cache = new(TimeProvider.System);
|
|
// The other central node's cache, reached only through the peer invalidation (audit D1).
|
|
private readonly RoleMappingCache _peerCache = new(TimeProvider.System);
|
|
private readonly PeerCacheInvalidationPublisher _publisher;
|
|
private readonly ServiceCollection _services = new ServiceCollection().AddTestConfigurationTransaction();
|
|
private List<LdapGroupMapping> _mappings = [new("Ops", "Deployer") { Id = 5 }];
|
|
|
|
/// <summary>Initializes the harness with one Deployer mapping for group "Ops".</summary>
|
|
public RoleMappingCacheInvalidationTests()
|
|
{
|
|
_repo.GetAllMappingsAsync(Arg.Any<CancellationToken>()).Returns(_ => _mappings);
|
|
_repo.GetScopeRulesForMappingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(new List<SiteScopeRule>());
|
|
_repo.GetMappingByIdAsync(5, Arg.Any<CancellationToken>()).Returns(_ => _mappings.FirstOrDefault());
|
|
_services.AddScoped(_ => _repo);
|
|
_services.AddScoped(_ => Substitute.For<IAuditService>());
|
|
_services.AddSingleton(_cache);
|
|
_publisher = new PeerCacheInvalidationPublisher(_peerCache);
|
|
_services.AddSingleton<ICentralCacheInvalidationPublisher>(_publisher);
|
|
}
|
|
|
|
private IActorRef CreateActor() =>
|
|
Sys.ActorOf(Props.Create(() => new ManagementActor(
|
|
_services.BuildServiceProvider(), NullLogger<ManagementActor>.Instance)));
|
|
|
|
private static ManagementEnvelope Admin(object command) =>
|
|
new(new AuthenticatedUser("admin", "Admin", new[] { "Administrator" }, Array.Empty<string>()),
|
|
command, Guid.NewGuid().ToString("N"));
|
|
|
|
private Task<RoleMappingResult> MapOps() => new RoleMapper(_repo, _cache).MapGroupsToRolesAsync(["Ops"]);
|
|
|
|
/// <summary>A deleted mapping is visible to the very next mapping on the same cache.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task DeleteRoleMapping_IsVisibleToTheNextMapping()
|
|
{
|
|
Assert.Contains("Deployer", (await MapOps()).Roles); // primes the snapshot
|
|
|
|
_mappings = [];
|
|
CreateActor().Tell(Admin(new DeleteRoleMappingCommand(5)));
|
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
|
|
Assert.Empty((await MapOps()).Roles);
|
|
}
|
|
|
|
/// <summary>Every mapping / scope-rule mutation invalidates the snapshot.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task EveryMappingMutation_InvalidatesTheSnapshot()
|
|
{
|
|
object[] commands =
|
|
[
|
|
new CreateRoleMappingCommand("Eng", "Designer"),
|
|
new UpdateRoleMappingCommand(5, "Ops", "Viewer"),
|
|
new AddScopeRuleCommand(5, 2),
|
|
new DeleteScopeRuleCommand(9),
|
|
];
|
|
var actor = CreateActor();
|
|
foreach (var command in commands)
|
|
{
|
|
await MapOps(); // prime
|
|
_repo.ClearReceivedCalls();
|
|
|
|
actor.Tell(Admin(command));
|
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
|
|
await MapOps();
|
|
await _repo.Received(1).GetAllMappingsAsync(Arg.Any<CancellationToken>()); // re-read, not served stale
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Audit D1 (ScadaBridge#100 item 3): a revocation made through this node reaches the peer
|
|
/// central node's snapshot at once, instead of after <c>RoleMappingCacheSeconds</c>.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task DeleteRoleMapping_IsVisibleOnThePeerNode()
|
|
{
|
|
var peerMapper = new RoleMapper(_repo, _peerCache);
|
|
Assert.Contains("Deployer", (await peerMapper.MapGroupsToRolesAsync(["Ops"])).Roles); // primes the peer
|
|
|
|
_mappings = [];
|
|
CreateActor().Tell(Admin(new DeleteRoleMappingCommand(5)));
|
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
|
|
Assert.Empty((await peerMapper.MapGroupsToRolesAsync(["Ops"])).Roles);
|
|
}
|
|
|
|
/// <summary>Every mapping / scope-rule mutation tells the peer, once, after commit.</summary>
|
|
[Fact]
|
|
public void EveryMappingMutation_TellsThePeer()
|
|
{
|
|
object[] commands =
|
|
[
|
|
new CreateRoleMappingCommand("Eng", "Designer"),
|
|
new UpdateRoleMappingCommand(5, "Ops", "Viewer"),
|
|
new DeleteRoleMappingCommand(5),
|
|
new AddScopeRuleCommand(5, 2),
|
|
new DeleteScopeRuleCommand(9),
|
|
];
|
|
var actor = CreateActor();
|
|
foreach (var command in commands)
|
|
{
|
|
actor.Tell(Admin(command));
|
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
Assert.Equal(commands.Length, _publisher.Published.Count);
|
|
Assert.All(_publisher.Published, i => Assert.IsType<RoleMappingsInvalidated>(i));
|
|
}
|
|
}
|