Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ClusterAuditQueryTests.cs
T
Joseph Doherty b7f5e887ee feat(audit): OtOpcUa ConfigAuditLog.Outcome column + migration + ClusterAudit visibility fix (Task 2.2)
Persist the canonical AuditOutcome and make structured audit rows visible.

- ConfigAuditLog gains a nullable Outcome column, stored as the AuditOutcome
  enum member name (nvarchar(16), mirroring how AdminRole is persisted). The
  AuditWriterActor flush now writes Outcome = evt.Outcome.ToString(). Nullable so
  legacy rows and the bespoke stored-procedure path (no derived outcome) write
  NULL.
- Migration 20260602135350_AddConfigAuditLogOutcome: additive nullable column,
  no backfill. Up adds the column, Down drops it. Chains after
  20260602112419_CanonicalizeAdminRoles; `dotnet ef migrations
  has-pending-model-changes` is clean.
- ClusterAudit visibility fix: the page filtered solely on ClusterId, but the
  structured AuditWriterActor path stamps NodeId (ClusterId null), so those rows
  were invisible. Extracted ClusterAuditQuery.ForClusterAsync (shared by the page
  and tests) which ORs in rows whose NodeId belongs to a node in the cluster —
  membership resolved from ClusterNode (NodeId -> ClusterId). SP-path
  ClusterId-stamped rows still match.

Tests: ControlPlane 45/45 (adds Outcome persistence + Denied-outcome asserts);
new Configuration ClusterAuditQueryTests 3/3 (both-paths visible, other-cluster
excluded, page-size cap); AdminUI 121/121. Configuration Unit suite is green on a
clean run (a pre-existing timing flake in ResilientConfigReaderTests, untouched
here, occasionally fails under parallel load and passes in isolation).
2026-06-02 09:59:22 -04:00

109 lines
4.4 KiB
C#

using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Queries;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// Verifies <see cref="ClusterAuditQuery.ForClusterAsync"/> — the cluster-scoped audit view used
/// by the AdminUI ClusterAudit page. The structured AuditWriterActor path stamps NodeId (not
/// ClusterId), so before the Task 2.2 fix those rows were invisible to a cluster filtered only on
/// ClusterId. These tests pin the OR-predicate that joins NodeId back to its cluster.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ClusterAuditQueryTests : IDisposable
{
private readonly OtOpcUaConfigDbContext _db;
/// <summary>Initializes a new instance with a fresh in-memory config database.</summary>
public ClusterAuditQueryTests()
{
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase($"cluster-audit-{Guid.NewGuid():N}")
.Options;
_db = new OtOpcUaConfigDbContext(options);
}
/// <summary>Disposes the database context.</summary>
public void Dispose() => _db.Dispose();
private void SeedNode(string clusterId, string nodeId) =>
_db.ClusterNodes.Add(new ClusterNode
{
NodeId = nodeId,
ClusterId = clusterId,
Host = $"{nodeId}.local",
ApplicationUri = $"urn:{nodeId}",
CreatedBy = "test",
});
private void SeedAudit(string eventType, string? clusterId, string? nodeId, DateTime ts) =>
_db.ConfigAuditLogs.Add(new ConfigAuditLog
{
Principal = "tester",
EventType = eventType,
ClusterId = clusterId,
NodeId = nodeId,
Timestamp = ts,
});
/// <summary>Structured rows (ClusterId null, NodeId set) for a node in the cluster are now
/// visible, alongside the SP-path rows that stamp ClusterId directly.</summary>
[Fact]
public async Task Surfaces_both_clusterId_rows_and_structured_nodeId_rows()
{
SeedNode("LINE3-OPCUA", "LINE3-OPCUA-A");
SeedNode("LINE3-OPCUA", "LINE3-OPCUA-B");
SeedNode("OTHER-CLUSTER", "OTHER-A");
var t0 = new DateTime(2026, 6, 2, 10, 0, 0, DateTimeKind.Utc);
// SP path: stamps ClusterId.
SeedAudit("Published", clusterId: "LINE3-OPCUA", nodeId: null, ts: t0);
// Structured AuditWriterActor path: stamps NodeId, ClusterId null — these were invisible.
SeedAudit("DraftEdited", clusterId: null, nodeId: "LINE3-OPCUA-A", ts: t0.AddMinutes(1));
SeedAudit("NodeApplied", clusterId: null, nodeId: "LINE3-OPCUA-B", ts: t0.AddMinutes(2));
// Noise that must NOT appear: other cluster's structured row + an orphan NodeId.
SeedAudit("Published", clusterId: null, nodeId: "OTHER-A", ts: t0.AddMinutes(3));
SeedAudit("Published", clusterId: null, nodeId: "UNKNOWN-NODE", ts: t0.AddMinutes(4));
await _db.SaveChangesAsync();
var rows = await ClusterAuditQuery.ForClusterAsync(_db, "LINE3-OPCUA", pageSize: 200);
rows.Select(r => r.EventType).ShouldBe(
["NodeApplied", "DraftEdited", "Published"], // newest first
ignoreOrder: false);
}
/// <summary>An audit row stamped with another cluster's ClusterId never appears.</summary>
[Fact]
public async Task Does_not_surface_other_cluster_rows()
{
SeedNode("LINE3-OPCUA", "LINE3-OPCUA-A");
var t0 = new DateTime(2026, 6, 2, 10, 0, 0, DateTimeKind.Utc);
SeedAudit("Published", clusterId: "OTHER-CLUSTER", nodeId: null, ts: t0);
await _db.SaveChangesAsync();
var rows = await ClusterAuditQuery.ForClusterAsync(_db, "LINE3-OPCUA", pageSize: 200);
rows.ShouldBeEmpty();
}
/// <summary>Respects the page-size cap, newest first.</summary>
[Fact]
public async Task Caps_to_page_size_newest_first()
{
SeedNode("LINE3-OPCUA", "LINE3-OPCUA-A");
var t0 = new DateTime(2026, 6, 2, 10, 0, 0, DateTimeKind.Utc);
for (var i = 0; i < 5; i++)
SeedAudit("DraftEdited", clusterId: null, nodeId: "LINE3-OPCUA-A", ts: t0.AddMinutes(i));
await _db.SaveChangesAsync();
var rows = await ClusterAuditQuery.ForClusterAsync(_db, "LINE3-OPCUA", pageSize: 3);
rows.Count.ShouldBe(3);
rows.First().Timestamp.ShouldBe(t0.AddMinutes(4)); // newest
}
}