using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
///
/// #520 stage 6 — the decision cache behind the request-rate seams.
///
///
/// The risk in a cache like this is not that it is slow; it is that it answers a question slightly
/// different from the one asked. Each test below pins one dimension the key must discriminate on,
/// because a key that collides across any of them hands one session another's authority.
///
public sealed class SessionAclDecisionCacheTests
{
private const string Session = "key-1";
private const string Node = "Plant/Modbus/dev1/Speed";
/// The second identical question is answered from the cache.
[Fact]
public void A_repeated_question_is_not_recomputed()
{
var cache = new SessionAclDecisionCache();
var calls = 0;
for (var i = 0; i < 5; i++)
Ask(cache, generation: 1, compute: () => { calls++; return true; }).ShouldBeTrue();
calls.ShouldBe(1);
}
///
/// A new generation drops everything. This is the invalidation the whole design rests on: grants
/// only change by deployment, and a deployment always advances the generation.
///
[Fact]
public void A_new_generation_invalidates_every_decision()
{
var cache = new SessionAclDecisionCache();
Ask(cache, generation: 1, compute: () => true).ShouldBeTrue();
cache.Count.ShouldBe(1);
// The same question, one deployment later, must be re-decided — and the new answer must win.
Ask(cache, generation: 2, compute: () => false).ShouldBeFalse();
cache.Generation.ShouldBe(2);
}
///
/// Two sessions asking about the same node get their own answers. A key that omitted the session
/// would let the first caller's verdict decide for everyone.
///
[Fact]
public void Two_sessions_do_not_share_a_decision()
{
var cache = new SessionAclDecisionCache();
Ask(cache, generation: 1, session: "granted-key", compute: () => true).ShouldBeTrue();
Ask(cache, generation: 1, session: "ungranted-key", compute: () => false).ShouldBeFalse();
}
/// Read and Write on one node are separate questions.
[Fact]
public void Two_operations_do_not_share_a_decision()
{
var cache = new SessionAclDecisionCache();
Ask(cache, generation: 1, operation: OpcUaOperation.Read, compute: () => true).ShouldBeTrue();
Ask(cache, generation: 1, operation: OpcUaOperation.WriteOperate, compute: () => false).ShouldBeFalse();
}
///
/// The two realms are separate namespaces that can carry the SAME s= identifier, so the realm
/// is part of the key. Without it, a raw grant would answer for the UNS node of the same path —
/// which is exactly the cross-realm confusion the trie itself is careful to avoid.
///
[Fact]
public void The_two_realms_do_not_share_a_decision()
{
var cache = new SessionAclDecisionCache();
Ask(cache, generation: 1, realm: AddressSpaceRealm.Raw, compute: () => true).ShouldBeTrue();
Ask(cache, generation: 1, realm: AddressSpaceRealm.Uns, compute: () => false).ShouldBeFalse();
}
/// Different nodes are different questions.
[Fact]
public void Two_nodes_do_not_share_a_decision()
{
var cache = new SessionAclDecisionCache();
Ask(cache, generation: 1, node: "Plant/Modbus/dev1/Speed", compute: () => true).ShouldBeTrue();
Ask(cache, generation: 1, node: "Plant/Modbus/dev1/Torque", compute: () => false).ShouldBeFalse();
}
///
/// Node identifiers are path segments, so they compare ordinally — Speed and speed can
/// both exist under one device and must not collide in the cache when they do not collide in the
/// trie.
///
[Fact]
public void Node_identifiers_are_case_sensitive()
{
var cache = new SessionAclDecisionCache();
Ask(cache, generation: 1, node: "Plant/dev1/Speed", compute: () => true).ShouldBeTrue();
Ask(cache, generation: 1, node: "Plant/dev1/speed", compute: () => false).ShouldBeFalse();
}
///
/// The cache is bounded. A client walking a huge address space must not be able to grow it without
/// limit; clearing at the ceiling degrades to recomputation, which is the no-cache steady state.
///
[Fact]
public void The_cache_is_bounded()
{
var cache = new SessionAclDecisionCache();
for (var i = 0; i <= SessionAclDecisionCache.MaxEntries + 10; i++)
Ask(cache, generation: 1, node: $"Plant/dev1/Tag{i}", compute: () => true);
cache.Count.ShouldBeLessThanOrEqualTo(SessionAclDecisionCache.MaxEntries);
}
///
/// A decision taken while a deployment lands mid-walk is returned but NOT stored, so the new
/// generation never inherits a verdict its own trie did not make.
///
[Fact]
public void A_decision_overtaken_by_a_deploy_is_not_stored()
{
var cache = new SessionAclDecisionCache();
// The compute callback simulates the deploy landing while the walk is in flight.
var decided = cache.GetOrAdd(
generation: 1, Session, OpcUaOperation.Read, AddressSpaceRealm.Raw, Node,
() =>
{
Ask(cache, generation: 2, node: "unrelated", compute: () => true);
return true;
});
decided.ShouldBeTrue("the caller still gets the answer its own walk produced");
// Nothing was filed under the superseded generation — the only entry is the unrelated one the
// callback itself made.
cache.Generation.ShouldBe(2);
cache.Count.ShouldBe(1);
}
private static bool Ask(
SessionAclDecisionCache cache,
long generation,
Func compute,
string session = Session,
OpcUaOperation operation = OpcUaOperation.Read,
AddressSpaceRealm realm = AddressSpaceRealm.Raw,
string node = Node)
=> cache.GetOrAdd(generation, session, operation, realm, node, compute);
}