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
119 lines
5.0 KiB
C#
119 lines
5.0 KiB
C#
using Akka.Actor;
|
|
using Akka.Cluster.Tools.PublishSubscribe;
|
|
using Akka.TestKit.Xunit2;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
|
|
|
/// <summary>
|
|
/// Gitea #96: an in-place rotation of <c>SB-GRPC-PSK-{siteId}</c> written through the secrets
|
|
/// write path must drop central's cached key on this node and on the peer central node. Since
|
|
/// ScadaBridge#100 item 3 the peer is told over the shared central cache-invalidation topic.
|
|
/// </summary>
|
|
public class SitePskRotationInvalidatorTests : TestKit
|
|
{
|
|
private readonly ISecretCacheInvalidator _inner = Substitute.For<ISecretCacheInvalidator>();
|
|
private readonly ISitePskProvider _psk = Substitute.For<ISitePskProvider>();
|
|
private readonly CentralCacheInvalidationBus _bus = new(NullLogger<CentralCacheInvalidationBus>.Instance);
|
|
|
|
private SitePskRotationInvalidator Create(ICentralCacheInvalidationPublisher? publisher) =>
|
|
new(_inner, _psk, publisher, NullLogger<SitePskRotationInvalidator>.Instance);
|
|
|
|
/// <summary>
|
|
/// Verifies that writing a site's PSK secret evicts the resolver cache, drops the site's
|
|
/// cached key and publishes the invalidation on the shared topic and, for a mixed-version
|
|
/// peer, bare on the legacy #96 topic.
|
|
/// </summary>
|
|
[Fact]
|
|
public void WritingAPskSecret_DropsTheCachedKey_AndTellsThePeer()
|
|
{
|
|
var mediator = CreateTestProbe();
|
|
_bus.SetMediator(mediator.Ref);
|
|
var invalidator = Create(_bus);
|
|
|
|
// The secrets library lowercases names, so the site id arrives lowercased too.
|
|
invalidator.Invalidate(new SecretName("SB-GRPC-PSK-site-a"));
|
|
|
|
_inner.Received(1).Invalidate(new SecretName("SB-GRPC-PSK-site-a"));
|
|
_psk.Received(1).Invalidate("site-a");
|
|
var shared = mediator.ExpectMsg<Publish>();
|
|
Assert.Equal(CentralCacheInvalidationBus.Topic, shared.Topic);
|
|
var envelope = Assert.IsType<CentralCacheInvalidationEnvelope>(shared.Message);
|
|
Assert.Equal(new SitePskInvalidated("site-a"), envelope.Invalidation);
|
|
Assert.Equal(_bus.OriginId, envelope.OriginId);
|
|
var legacy = mediator.ExpectMsg<Publish>();
|
|
Assert.Equal("site-psk-invalidation", legacy.Topic);
|
|
Assert.Equal(new SitePskInvalidated("site-a"), legacy.Message);
|
|
}
|
|
|
|
/// <summary>Negative control: any other secret only evicts the resolver cache.</summary>
|
|
[Fact]
|
|
public void WritingAnyOtherSecret_OnlyEvictsTheResolverCache()
|
|
{
|
|
var mediator = CreateTestProbe();
|
|
_bus.SetMediator(mediator.Ref);
|
|
var invalidator = Create(_bus);
|
|
|
|
invalidator.Invalidate(new SecretName("SMTP-PASSWORD"));
|
|
|
|
_inner.Received(1).Invalidate(new SecretName("SMTP-PASSWORD"));
|
|
_psk.DidNotReceiveWithAnyArgs().Invalidate(default!);
|
|
mediator.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
|
}
|
|
|
|
/// <summary>Verifies that without a publisher (or before the actor system exists) an invalidation still applies locally.</summary>
|
|
[Fact]
|
|
public void WithoutAPublisher_TheLocalInvalidationStillApplies()
|
|
{
|
|
Create(null).Invalidate(new SecretName("SB-GRPC-PSK-site-a"));
|
|
Create(_bus).Invalidate(new SecretName("SB-GRPC-PSK-site-b")); // bus has no mediator yet
|
|
|
|
_psk.Received(1).Invalidate("site-a");
|
|
_psk.Received(1).Invalidate("site-b");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the subscriber applies a peer's invalidation to this node's resolver and PSK
|
|
/// caches, without re-publishing it.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Subscriber_AppliesAPeerInvalidation_WithoutRepublishing()
|
|
{
|
|
var mediator = CreateTestProbe();
|
|
_bus.SetMediator(mediator.Ref);
|
|
var invalidator = Create(_bus);
|
|
var subscriber = Sys.ActorOf(Props.Create(() => new CentralCacheInvalidationSubscriber(
|
|
_bus, new ICentralCacheInvalidationHandler[] { invalidator })));
|
|
|
|
subscriber.Tell(new CentralCacheInvalidationEnvelope("peer-node", new SitePskInvalidated("site-b")));
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
_psk.Received(1).Invalidate("site-b");
|
|
_inner.Received(1).Invalidate(new SecretName("SB-GRPC-PSK-site-b"));
|
|
});
|
|
mediator.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Migration: a bare <see cref="SitePskInvalidated"/> from a peer still on the #96 topic is
|
|
/// applied too.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Subscriber_AppliesALegacyBarePskInvalidation()
|
|
{
|
|
var invalidator = Create(_bus);
|
|
var subscriber = Sys.ActorOf(Props.Create(() => new CentralCacheInvalidationSubscriber(
|
|
_bus, new ICentralCacheInvalidationHandler[] { invalidator })));
|
|
|
|
subscriber.Tell(new SitePskInvalidated("site-c"));
|
|
|
|
AwaitAssert(() => _psk.Received(1).Invalidate("site-c"));
|
|
}
|
|
}
|