test(opcua): pin clock-driven expiry in the revalidation pass; correct the only-trigger remark

This commit is contained in:
Joseph Doherty
2026-08-20 09:50:39 -04:00
parent bd80ff96ca
commit b09f78396f
4 changed files with 186 additions and 10 deletions
@@ -615,8 +615,156 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable
finally { await host.DisposeAsync(); }
}
/// <summary>
/// A key that expires by the CLOCK stands its live monitored item down, with no deployment
/// anywhere in the test (arch-review 03/S19).
/// </summary>
/// <remarks>
/// <para>
/// The first pass is the falsification control, and it is not optional: without it a build
/// whose pass detached everything it saw — unconditionally, or because the authorizer denied
/// for some unrelated reason — would pass the second half and prove nothing about expiry.
/// </para>
/// <para>
/// <c>DataPlaneAuthorityState.Apply</c> is called exactly once here, in setup. Nothing
/// advances the generation between the two passes, so the only thing that changed is the
/// clock — which is the whole claim.
/// </para>
/// </remarks>
[Fact]
public async Task An_expired_key_stands_down_a_live_item_with_no_deploy()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var clock = new FakeTimeProvider(new DateTime(2026, 8, 20, 12, 0, 0, DateTimeKind.Utc));
nm.Authorizer = KeyedSubscribeAuthorizer(
clock, out _, expiresUtc: new DateTime(2026, 8, 20, 12, 10, 0, DateTimeKind.Utc));
var item = TrackItem(nm, Variable(nm, "Plant/Modbus/dev1/Speed"), KeyedIdentity());
nm.RevalidateMonitoredItems();
item.Denials.ShouldBe(0, "the key is still valid, so nothing may stand down");
clock.Advance(TimeSpan.FromMinutes(20));
nm.RevalidateMonitoredItems();
item.Denials.ShouldBe(1);
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// A key renewed with a later expiry re-attaches its item on the next pass — the clock axis
/// inherits the deploy axis's reversibility.
/// </summary>
/// <remarks>
/// The restore is asserted by standing the item down a SECOND time: the pass skips an item that is
/// already suppressed, so a second denial can only be queued if the item was genuinely restored
/// in between. That is why this pins the design decision not to close the session — a client that
/// renews its key recovers with no action of its own.
/// </remarks>
[Fact]
public async Task A_renewed_key_re_attaches_the_item_on_the_next_pass()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var clock = new FakeTimeProvider(new DateTime(2026, 8, 20, 12, 0, 0, DateTimeKind.Utc));
nm.Authorizer = KeyedSubscribeAuthorizer(
clock, out var state, expiresUtc: new DateTime(2026, 8, 20, 12, 10, 0, DateTimeKind.Utc));
var item = TrackItem(nm, Variable(nm, "Plant/Modbus/dev1/Speed"), KeyedIdentity());
clock.Advance(TimeSpan.FromMinutes(20));
nm.RevalidateMonitoredItems();
item.Denials.ShouldBe(1, "non-vacuity: the item really was stood down before the renewal");
// Renew: same key id, a later expiry.
state.Apply(
Cluster, "rev-2",
[Key(new DateTime(2026, 8, 20, 14, 0, 0, DateTimeKind.Utc))],
[GrantOn(NodePermissions.Subscribe)],
Scopes());
nm.RevalidateMonitoredItems();
// Expire it again. A still-suppressed item is skipped, so a second denial proves the restore.
clock.Advance(TimeSpan.FromHours(3));
nm.RevalidateMonitoredItems();
item.Denials.ShouldBe(2);
}
finally { await host.DisposeAsync(); }
}
/// <summary>A clock that moves but does not reach the expiry stands nothing down.</summary>
[Fact]
public async Task An_unexpired_key_is_untouched_by_a_clock_only_pass()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var clock = new FakeTimeProvider(new DateTime(2026, 8, 20, 12, 0, 0, DateTimeKind.Utc));
nm.Authorizer = KeyedSubscribeAuthorizer(
clock, out _, expiresUtc: new DateTime(2026, 8, 20, 13, 0, 0, DateTimeKind.Utc));
var item = TrackItem(nm, Variable(nm, "Plant/Modbus/dev1/Speed"), KeyedIdentity());
clock.Advance(TimeSpan.FromMinutes(30));
nm.RevalidateMonitoredItems();
item.Denials.ShouldBe(0);
}
finally { await host.DisposeAsync(); }
}
// ───────────────────────────── helpers ─────────────────────────────
/// <summary>The public id of the key the clock-driven expiry cases authenticate with.</summary>
private const string ExpiringKeyId = "k-expiry";
/// <summary>
/// A cluster-wide Subscribe grant PLUS a key that expires at <paramref name="expiresUtc"/>, so the
/// verdict moves with the clock rather than with a deployment.
/// </summary>
/// <param name="clock">The authorizer's clock — the one <c>Evaluate</c> checks expiry against.</param>
/// <param name="state">The authority state, so a test can renew the key without a second helper.</param>
/// <param name="expiresUtc">When the key stops being usable.</param>
/// <returns>The gate, enforcing.</returns>
private static DataPlaneAuthorizer KeyedSubscribeAuthorizer(
TimeProvider clock, out DataPlaneAuthorityState state, DateTime expiresUtc)
{
state = new DataPlaneAuthorityState();
state.Apply(Cluster, "rev-1", [Key(expiresUtc)], [GrantOn(NodePermissions.Subscribe)], Scopes());
return new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance, clock);
}
/// <summary>The expiring key, at whatever expiry a case needs.</summary>
/// <param name="expiresUtc">When the key stops being usable.</param>
/// <returns>The key row.</returns>
private static ApiKey Key(DateTime expiresUtc) => new()
{
ApiKeyId = ExpiringKeyId,
Name = ExpiringKeyId,
SecretHash = "hash",
Principal = Principal,
Enabled = true,
CreatedUtc = new DateTime(2026, 8, 20, 0, 0, 0, DateTimeKind.Utc),
ExpiresUtc = expiresUtc,
};
/// <summary>
/// An identity carrying the key id, which is what makes the per-call expiry re-check apply — an
/// identity with no <c>ApiKeyId</c> skips it entirely.
/// </summary>
/// <returns>The identity.</returns>
private static RoleCarryingUserIdentity KeyedIdentity() =>
new(new UserNameIdentityToken { UserName = "apikey" }, [], Principal, ExpiringKeyId);
/// <summary>A cluster-wide Subscribe grant — what <c>CreateMonitoredItems</c> resolves to.</summary>
private static DataPlaneAuthorizer SubscribeAuthorizer(TimeProvider clock)
{