Phase 1 Streams B–E scaffold + Phase 2 Streams A–C scaffold — 8 new projects with ~70 new tests, all green alongside the 494 v1 IntegrationTests baseline (parity preserved: no v1 tests broken; legacy OtOpcUa.Host untouched). Phase 1 finish: Configuration project (16 entities + 10 enums + DbContext + DesignTimeDbContextFactory + InitialSchema/StoredProcedures/AuthorizationGrants migrations — 8 procs including sp_PublishGeneration with MERGE on ExternalIdReservation per decision #124, sp_RollbackToGeneration cloning rows into a new published generation, sp_ValidateDraft with cross-cluster-namespace + EquipmentUuid-immutability + ZTag/SAPID reservation pre-flight, sp_ComputeGenerationDiff with CHECKSUM-based row signature — plus OtOpcUaNode/OtOpcUaAdmin SQL roles with EXECUTE grants scoped to per-principal-class proc sets and DENY UPDATE/DELETE/INSERT/SELECT on dbo schema); managed DraftValidator covering UNS segment regex, path length, EquipmentUuid immutability across generations, same-cluster namespace binding (decision #122), reservation pre-flight, EquipmentId derivation (decision #125), driver↔namespace compatibility — returning every failing rule in one pass; LiteDB local cache with round-trip + ring pruning + corruption-fast-fail; GenerationApplier with per-entity Added/Removed/Modified diff and dependency-ordered callbacks (namespace → driver → device → equipment → poll-group → tag, Removed before Added); Core project with GenericDriverNodeManager (scaffold for the Phase 2 Galaxy port) and DriverHost lifecycle registry; Server project using Microsoft.Extensions.Hosting BackgroundService replacing TopShelf, with NodeBootstrap that falls back to LiteDB cache when the central DB is unreachable (decision #79); Admin project scaffolded as Blazor Server with Bootstrap 5 sidebar layout, cookie auth, three admin roles (ConfigViewer/ConfigEditor/FleetAdmin), Cluster + Generation services fronting the stored procs. Phase 2 scaffold: Driver.Galaxy.Shared (netstandard2.0) with full MessagePack IPC contract surface — Hello version negotiation, Open/CloseSession, Heartbeat, DiscoverHierarchy + GalaxyObjectInfo/GalaxyAttributeInfo, Read/WriteValues, Subscribe/Unsubscribe/OnDataChange, AlarmSubscribe/Event/Ack, HistoryRead, HostConnectivityStatus, Recycle — plus length-prefixed framing (decision #28) with a 16 MiB cap and thread-safe FrameWriter/FrameReader; Driver.Galaxy.Host (net48) implementing the Tier C cross-cutting protections from driver-stability.md — strict PipeAcl (allow configured server SID only, explicit deny on LocalSystem + Administrators), PipeServer with caller-SID verification via pipe.RunAsClient + WindowsIdentity.GetCurrent and per-process shared-secret Hello, Galaxy-specific MemoryWatchdog (warn at max(1.5×baseline, +200 MB), soft-recycle at max(2×baseline, +200 MB), hard ceiling 1.5 GB, slope ≥5 MB/min over 30-min rolling window), RecyclePolicy (1 soft recycle per hour cap + 03:00 local daily scheduled), PostMortemMmf (1000-entry ring buffer in %ProgramData%\OtOpcUa\driver-postmortem\galaxy.mmf, survives hard crash, readable cross-process), MxAccessHandle : SafeHandle (ReleaseHandle loops Marshal.ReleaseComObject until refcount=0 then calls optional unregister callback), StaPump with responsiveness probe (BlockingCollection dispatcher for Phase 1 — real Win32 GetMessage/DispatchMessage pump slots in with the same semantics when the Galaxy code lift happens), IsExternalInit shim for init setters on .NET 4.8; Driver.Galaxy.Proxy (net10) implementing IDriver + ITagDiscovery forwarding over the IPC channel with MX data-type and security-classification mapping, plus Supervisor pieces — Backoff (5s → 15s → 60s capped, reset-on-stable-run), CircuitBreaker (3 crashes per 5 min opens; 1h → 4h → manual cooldown escalation; sticky alert doesn't auto-clear), HeartbeatMonitor (2s cadence, 3 consecutive misses = host dead per driver-stability.md). Infrastructure: docker SQL Server remapped to host port 14330 to coexist with the native MSSQL14 Galaxy ZB DB instance on 1433; NuGetAuditSuppress applied per-project for two System.Security.Cryptography.Xml advisories that only reach via EF Core Design with PrivateAssets=all (fix ships in 11.0.0-preview); .slnx gains 14 project registrations. Deferred with explicit TODOs in docs/v2/implementation/phase-2-partial-exit-evidence.md: Phase 1 Stream E Admin UI pages (Generations listing + draft-diff-publish, Equipment CRUD with OPC 40010 fields, UNS Areas/Lines tabs, ACLs + permission simulator, Generic JSON config editor, SignalR real-time, Release-Reservation + Merge-Equipment workflows, LDAP login page, AppServer smoke test per decision #142), Phase 2 Stream D (Galaxy MXAccess code lift out of legacy OtOpcUa.Host, dual-service installer, appsettings → DriverConfig migration script, legacy Host deletion — blocked by parity), Phase 2 Stream E (v1 IntegrationTests against v2 topology, Client.CLI walkthrough diff, four 2026-04-13 stability findings regression tests, adversarial review — requires live MXAccess runtime).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-17 21:35:25 -04:00
parent fc0ce36308
commit 01fd90c178
128 changed files with 12352 additions and 4 deletions

View File

@@ -0,0 +1,18 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class AdminRolesTests
{
[Fact]
public void All_contains_three_canonical_roles()
{
AdminRoles.All.Count.ShouldBe(3);
AdminRoles.All.ShouldContain(AdminRoles.ConfigViewer);
AdminRoles.All.ShouldContain(AdminRoles.ConfigEditor);
AdminRoles.All.ShouldContain(AdminRoles.FleetAdmin);
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Admin.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Admin\ZB.MOM.WW.OtOpcUa.Admin.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,162 @@
using Microsoft.Data.SqlClient;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// Creates two throwaway DB users — one in <c>OtOpcUaNode</c>, one in <c>OtOpcUaAdmin</c> —
/// and verifies the grants/denies from the <c>AuthorizationGrants</c> migration.
/// </summary>
[Trait("Category", "Authorization")]
[Collection(nameof(SchemaComplianceCollection))]
public sealed class AuthorizationTests
{
private readonly SchemaComplianceFixture _fixture;
public AuthorizationTests(SchemaComplianceFixture fixture) => _fixture = fixture;
[Fact]
public void Node_role_can_execute_GetCurrentGenerationForCluster_but_not_PublishGeneration()
{
var (user, password) = CreateUserInRole(_fixture, "Node");
try
{
using var conn = OpenAs(user, password);
Should.Throw<SqlException>(() =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "EXEC dbo.sp_PublishGeneration @ClusterId='x', @DraftGenerationId=1";
cmd.ExecuteNonQuery();
}).Message.ShouldContain("permission", Case.Insensitive);
// Calling a granted proc authenticates; the proc itself will RAISERROR with Unauthorized
// because our test principal isn't bound to any node — that's expected.
var ex = Should.Throw<SqlException>(() =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "EXEC dbo.sp_GetCurrentGenerationForCluster @NodeId='n', @ClusterId='c'";
cmd.ExecuteNonQuery();
});
ex.Message.ShouldContain("Unauthorized");
}
finally
{
DropUser(_fixture, user);
}
}
[Fact]
public void Node_role_cannot_SELECT_from_tables_directly()
{
var (user, password) = CreateUserInRole(_fixture, "Node");
try
{
using var conn = OpenAs(user, password);
var ex = Should.Throw<SqlException>(() =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM dbo.ConfigGeneration";
cmd.ExecuteScalar();
});
ex.Message.ShouldContain("permission", Case.Insensitive);
}
finally
{
DropUser(_fixture, user);
}
}
[Fact]
public void Admin_role_can_execute_PublishGeneration()
{
var (user, password) = CreateUserInRole(_fixture, "Admin");
try
{
using var conn = OpenAs(user, password);
// Calling the proc is permitted; content-level errors (missing draft) are OK — they
// prove the grant succeeded (we got past the permission check into the proc body).
var ex = Should.Throw<SqlException>(() =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "EXEC dbo.sp_PublishGeneration @ClusterId='no-such-cluster', @DraftGenerationId=9999";
cmd.ExecuteNonQuery();
});
ex.Message.ShouldNotContain("permission", Case.Insensitive);
}
finally
{
DropUser(_fixture, user);
}
}
/// <summary>Creates a SQL login + DB user in the given role and returns its credentials.</summary>
private static (string User, string Password) CreateUserInRole(SchemaComplianceFixture fx, string role)
{
var user = $"tst_{role.ToLower()}_{Guid.NewGuid():N}"[..24];
const string password = "TestUser_2026!";
var dbRole = role == "Node" ? "OtOpcUaNode" : "OtOpcUaAdmin";
// Create the login in master, the user in the test DB, and add it to the role.
using (var conn = new SqlConnection(
new SqlConnectionStringBuilder(fx.ConnectionString) { InitialCatalog = "master" }.ConnectionString))
{
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"CREATE LOGIN [{user}] WITH PASSWORD = '{password}', CHECK_POLICY = OFF;";
cmd.ExecuteNonQuery();
}
using (var conn = fx.OpenConnection())
{
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
CREATE USER [{user}] FOR LOGIN [{user}];
ALTER ROLE {dbRole} ADD MEMBER [{user}];";
cmd.ExecuteNonQuery();
}
return (user, password);
}
private static void DropUser(SchemaComplianceFixture fx, string user)
{
try
{
using var dbConn = fx.OpenConnection();
using var cmd1 = dbConn.CreateCommand();
cmd1.CommandText = $"IF DATABASE_PRINCIPAL_ID('{user}') IS NOT NULL DROP USER [{user}];";
cmd1.ExecuteNonQuery();
}
catch { /* swallow — fixture disposes the DB anyway */ }
try
{
using var master = new SqlConnection(
new SqlConnectionStringBuilder(fx.ConnectionString) { InitialCatalog = "master" }.ConnectionString);
master.Open();
using var cmd = master.CreateCommand();
cmd.CommandText = $"IF SUSER_ID('{user}') IS NOT NULL DROP LOGIN [{user}];";
cmd.ExecuteNonQuery();
}
catch { /* ignore */ }
}
private SqlConnection OpenAs(string user, string password)
{
var cs = new SqlConnectionStringBuilder(_fixture.ConnectionString)
{
UserID = user,
Password = password,
IntegratedSecurity = false,
}.ConnectionString;
var conn = new SqlConnection(cs);
conn.Open();
return conn;
}
}

View File

@@ -0,0 +1,148 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
[Trait("Category", "Unit")]
public sealed class DraftValidatorTests
{
[Theory]
[InlineData("valid-name", true)]
[InlineData("line-01", true)]
[InlineData("_default", true)]
[InlineData("UPPER", false)]
[InlineData("with space", false)]
[InlineData("", false)]
public void UnsSegment_rule_accepts_lowercase_or_default_only(string name, bool shouldPass)
{
var uuid = Guid.NewGuid();
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
Equipment =
[
new Equipment
{
EquipmentUuid = uuid,
EquipmentId = DraftValidator.DeriveEquipmentId(uuid),
Name = name,
DriverInstanceId = "d",
UnsLineId = "line-a",
MachineCode = "m",
},
],
};
var errors = DraftValidator.Validate(draft);
var hasUnsError = errors.Any(e => e.Code == "UnsSegmentInvalid");
hasUnsError.ShouldBe(!shouldPass);
}
[Fact]
public void Cross_cluster_namespace_binding_is_rejected()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c-A",
Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c-B", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }],
DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c-A", NamespaceId = "ns-1", Name = "drv", DriverType = "ModbusTcp", DriverConfig = "{}" }],
};
var errors = DraftValidator.Validate(draft);
errors.ShouldContain(e => e.Code == "BadCrossClusterNamespaceBinding");
}
[Fact]
public void Same_cluster_namespace_binding_is_accepted()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c-A",
Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c-A", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }],
DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c-A", NamespaceId = "ns-1", Name = "drv", DriverType = "ModbusTcp", DriverConfig = "{}" }],
};
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "BadCrossClusterNamespaceBinding");
}
[Fact]
public void EquipmentUuid_change_across_generations_is_rejected()
{
var oldUuid = Guid.Parse("11111111-1111-1111-1111-111111111111");
var newUuid = Guid.Parse("22222222-2222-2222-2222-222222222222");
var eid = DraftValidator.DeriveEquipmentId(oldUuid);
var draft = new DraftSnapshot
{
GenerationId = 2, ClusterId = "c",
Equipment = [new Equipment { EquipmentUuid = newUuid, EquipmentId = eid, Name = "eq", DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m" }],
PriorEquipment = [new Equipment { EquipmentUuid = oldUuid, EquipmentId = eid, Name = "eq", DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m" }],
};
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentUuidImmutable");
}
[Fact]
public void ZTag_reserved_by_different_uuid_is_rejected()
{
var uuid = Guid.NewGuid();
var otherUuid = Guid.NewGuid();
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = DraftValidator.DeriveEquipmentId(uuid), Name = "eq", DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m", ZTag = "ZT-001" }],
ActiveReservations = [new ExternalIdReservation { Kind = ReservationKind.ZTag, Value = "ZT-001", EquipmentUuid = otherUuid, ClusterId = "c", FirstPublishedBy = "t" }],
};
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "BadDuplicateExternalIdentifier");
}
[Fact]
public void EquipmentId_that_does_not_match_derivation_is_rejected()
{
var uuid = Guid.NewGuid();
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = "EQ-operator-typed", Name = "eq", DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m" }],
};
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentIdNotDerived");
}
[Fact]
public void Galaxy_driver_in_Equipment_namespace_is_rejected()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }],
DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c", NamespaceId = "ns-1", Name = "drv", DriverType = "Galaxy", DriverConfig = "{}" }],
};
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "DriverNamespaceKindMismatch");
}
[Fact]
public void Draft_with_three_violations_surfaces_all_three()
{
var uuid = Guid.NewGuid();
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c-A",
Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c-B", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }],
DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c-A", NamespaceId = "ns-1", Name = "drv", DriverType = "Galaxy", DriverConfig = "{}" }],
Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = "EQ-wrong", Name = "BAD NAME", DriverInstanceId = "d-1", UnsLineId = "line-a", MachineCode = "m" }],
};
var errors = DraftValidator.Validate(draft);
errors.ShouldContain(e => e.Code == "BadCrossClusterNamespaceBinding");
errors.ShouldContain(e => e.Code == "DriverNamespaceKindMismatch");
errors.ShouldContain(e => e.Code == "EquipmentIdNotDerived");
errors.ShouldContain(e => e.Code == "UnsSegmentInvalid");
}
}

View File

@@ -0,0 +1,131 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Apply;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
[Trait("Category", "Unit")]
public sealed class GenerationApplierTests
{
private static DraftSnapshot SnapshotWith(
IReadOnlyList<DriverInstance>? drivers = null,
IReadOnlyList<Equipment>? equipment = null,
IReadOnlyList<Tag>? tags = null) => new()
{
GenerationId = 1, ClusterId = "c",
DriverInstances = drivers ?? [],
Equipment = equipment ?? [],
Tags = tags ?? [],
};
private static DriverInstance Driver(string id) =>
new() { DriverInstanceId = id, ClusterId = "c", NamespaceId = "ns", Name = id, DriverType = "ModbusTcp", DriverConfig = "{}" };
private static Equipment Eq(string id, Guid uuid) =>
new() { EquipmentUuid = uuid, EquipmentId = id, DriverInstanceId = "d", UnsLineId = "line-a", Name = id, MachineCode = id };
private static Tag Tag(string id, string name) =>
new() { TagId = id, DriverInstanceId = "d", Name = name, FolderPath = "/a", DataType = "Int32", AccessLevel = TagAccessLevel.Read, TagConfig = "{}" };
[Fact]
public void Diff_from_empty_to_one_driver_five_equipment_fifty_tags_is_all_Added()
{
var uuid = (int i) => Guid.Parse($"00000000-0000-0000-0000-{i:000000000000}");
var equipment = Enumerable.Range(1, 5).Select(i => Eq($"eq-{i}", uuid(i))).ToList();
var tags = Enumerable.Range(1, 50).Select(i => Tag($"tag-{i}", $"T{i}")).ToList();
var diff = GenerationDiffer.Compute(from: null,
to: SnapshotWith(drivers: [Driver("d-1")], equipment: equipment, tags: tags));
diff.Drivers.Count.ShouldBe(1);
diff.Drivers.ShouldAllBe(c => c.Kind == ChangeKind.Added);
diff.Equipment.Count.ShouldBe(5);
diff.Equipment.ShouldAllBe(c => c.Kind == ChangeKind.Added);
diff.Tags.Count.ShouldBe(50);
diff.Tags.ShouldAllBe(c => c.Kind == ChangeKind.Added);
}
[Fact]
public void Diff_flags_single_tag_name_change_as_Modified_only_for_that_tag()
{
var before = SnapshotWith(tags: [Tag("tag-1", "Old"), Tag("tag-2", "Keep")]);
var after = SnapshotWith(tags: [Tag("tag-1", "New"), Tag("tag-2", "Keep")]);
var diff = GenerationDiffer.Compute(before, after);
diff.Tags.Count.ShouldBe(1);
diff.Tags[0].Kind.ShouldBe(ChangeKind.Modified);
diff.Tags[0].LogicalId.ShouldBe("tag-1");
}
[Fact]
public void Diff_flags_Removed_equipment_and_its_tags()
{
var uuid1 = Guid.NewGuid();
var before = SnapshotWith(
equipment: [Eq("eq-1", uuid1), Eq("eq-2", Guid.NewGuid())],
tags: [Tag("tag-1", "A"), Tag("tag-2", "B")]);
var after = SnapshotWith(
equipment: [Eq("eq-2", before.Equipment[1].EquipmentUuid)],
tags: [Tag("tag-2", "B")]);
var diff = GenerationDiffer.Compute(before, after);
diff.Equipment.ShouldContain(c => c.Kind == ChangeKind.Removed && c.LogicalId == "eq-1");
diff.Tags.ShouldContain(c => c.Kind == ChangeKind.Removed && c.LogicalId == "tag-1");
}
[Fact]
public async Task Apply_dispatches_callbacks_in_dependency_order_and_survives_idempotent_retry()
{
var callLog = new List<string>();
var applier = new GenerationApplier(new ApplyCallbacks
{
OnDriver = (c, _) => { callLog.Add($"drv:{c.Kind}:{c.LogicalId}"); return Task.CompletedTask; },
OnEquipment = (c, _) => { callLog.Add($"eq:{c.Kind}:{c.LogicalId}"); return Task.CompletedTask; },
OnTag = (c, _) => { callLog.Add($"tag:{c.Kind}:{c.LogicalId}"); return Task.CompletedTask; },
});
var to = SnapshotWith(
drivers: [Driver("d-1")],
equipment: [Eq("eq-1", Guid.NewGuid())],
tags: [Tag("tag-1", "A")]);
var result1 = await applier.ApplyAsync(from: null, to, CancellationToken.None);
result1.Succeeded.ShouldBeTrue();
// Driver Added must come before Equipment Added must come before Tag Added
var drvIdx = callLog.FindIndex(s => s.StartsWith("drv:Added"));
var eqIdx = callLog.FindIndex(s => s.StartsWith("eq:Added"));
var tagIdx = callLog.FindIndex(s => s.StartsWith("tag:Added"));
drvIdx.ShouldBeLessThan(eqIdx);
eqIdx.ShouldBeLessThan(tagIdx);
// Idempotent retry: re-applying the same diff must not blow up
var countBefore = callLog.Count;
var result2 = await applier.ApplyAsync(from: null, to, CancellationToken.None);
result2.Succeeded.ShouldBeTrue();
callLog.Count.ShouldBe(countBefore * 2);
}
[Fact]
public async Task Apply_collects_errors_from_failing_callback_without_aborting()
{
var applier = new GenerationApplier(new ApplyCallbacks
{
OnTag = (c, _) =>
c.LogicalId == "tag-bad"
? throw new InvalidOperationException("simulated")
: Task.CompletedTask,
});
var to = SnapshotWith(tags: [Tag("tag-ok", "A"), Tag("tag-bad", "B")]);
var result = await applier.ApplyAsync(from: null, to, CancellationToken.None);
result.Succeeded.ShouldBeFalse();
result.Errors.ShouldContain(e => e.Contains("tag-bad") && e.Contains("simulated"));
}
}

View File

@@ -0,0 +1,107 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
[Trait("Category", "Unit")]
public sealed class LiteDbConfigCacheTests : IDisposable
{
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-cache-test-{Guid.NewGuid():N}.db");
public void Dispose()
{
if (File.Exists(_dbPath)) File.Delete(_dbPath);
}
private GenerationSnapshot Snapshot(string cluster, long gen) => new()
{
ClusterId = cluster,
GenerationId = gen,
CachedAt = DateTime.UtcNow,
PayloadJson = $"{{\"g\":{gen}}}",
};
[Fact]
public async Task Roundtrip_preserves_payload()
{
using var cache = new LiteDbConfigCache(_dbPath);
var put = Snapshot("c-1", 42);
await cache.PutAsync(put);
var got = await cache.GetMostRecentAsync("c-1");
got.ShouldNotBeNull();
got!.GenerationId.ShouldBe(42);
got.PayloadJson.ShouldBe(put.PayloadJson);
}
[Fact]
public async Task GetMostRecent_returns_latest_when_multiple_generations_present()
{
using var cache = new LiteDbConfigCache(_dbPath);
foreach (var g in new long[] { 10, 20, 15 })
await cache.PutAsync(Snapshot("c-1", g));
var got = await cache.GetMostRecentAsync("c-1");
got!.GenerationId.ShouldBe(20);
}
[Fact]
public async Task GetMostRecent_returns_null_for_unknown_cluster()
{
using var cache = new LiteDbConfigCache(_dbPath);
(await cache.GetMostRecentAsync("ghost")).ShouldBeNull();
}
[Fact]
public async Task Prune_keeps_latest_N_and_drops_older()
{
using var cache = new LiteDbConfigCache(_dbPath);
for (long g = 1; g <= 15; g++)
await cache.PutAsync(Snapshot("c-1", g));
await cache.PruneOldGenerationsAsync("c-1", keepLatest: 10);
(await cache.GetMostRecentAsync("c-1"))!.GenerationId.ShouldBe(15);
// Drop them one by one and count — should be exactly 10 remaining
var count = 0;
while (await cache.GetMostRecentAsync("c-1") is not null)
{
count++;
await cache.PruneOldGenerationsAsync("c-1", keepLatest: Math.Max(0, 10 - count));
if (count > 20) break; // safety
}
count.ShouldBe(10);
}
[Fact]
public async Task Put_same_cluster_generation_twice_replaces_not_duplicates()
{
using var cache = new LiteDbConfigCache(_dbPath);
var first = Snapshot("c-1", 1);
first.PayloadJson = "{\"v\":1}";
await cache.PutAsync(first);
var second = Snapshot("c-1", 1);
second.PayloadJson = "{\"v\":2}";
await cache.PutAsync(second);
(await cache.GetMostRecentAsync("c-1"))!.PayloadJson.ShouldBe("{\"v\":2}");
}
[Fact]
public void Corrupt_file_surfaces_as_LocalConfigCacheCorruptException()
{
// Write a file large enough to look like a LiteDB page but with garbage contents so page
// deserialization fails on the first read probe.
File.WriteAllBytes(_dbPath, new byte[8192]);
Array.Fill<byte>(File.ReadAllBytes(_dbPath), 0xAB);
using (var fs = File.OpenWrite(_dbPath))
{
fs.Write(new byte[8192].Select(_ => (byte)0xAB).ToArray());
}
Should.Throw<LocalConfigCacheCorruptException>(() => new LiteDbConfigCache(_dbPath));
}
}

View File

@@ -0,0 +1,68 @@
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// Spins up a dedicated test database, applies the EF migrations against it, and exposes a
/// <see cref="SqlConnection"/> factory. Disposed at collection teardown (drops the DB).
/// Gated by the <c>OTOPCUA_CONFIG_TEST_SERVER</c> env var so CI runs can opt in explicitly;
/// local runs default to the dev container on <c>localhost:14330</c>.
/// </summary>
public sealed class SchemaComplianceFixture : IDisposable
{
private const string DefaultServer = "localhost,14330";
private const string DefaultSaPassword = "OtOpcUaDev_2026!";
public string DatabaseName { get; }
public string ConnectionString { get; }
public SchemaComplianceFixture()
{
var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer;
var saPassword = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword;
DatabaseName = $"OtOpcUaConfig_Test_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}";
ConnectionString =
$"Server={server};Database={DatabaseName};User Id=sa;Password={saPassword};TrustServerCertificate=True;Encrypt=False;";
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseSqlServer(ConnectionString)
.Options;
using var ctx = new OtOpcUaConfigDbContext(options);
ctx.Database.Migrate();
}
public SqlConnection OpenConnection()
{
var conn = new SqlConnection(ConnectionString);
conn.Open();
return conn;
}
public void Dispose()
{
var masterConnection =
new SqlConnectionStringBuilder(ConnectionString) { InitialCatalog = "master" }.ConnectionString;
using var conn = new SqlConnection(masterConnection);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
IF DB_ID(N'{DatabaseName}') IS NOT NULL
BEGIN
ALTER DATABASE [{DatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE [{DatabaseName}];
END";
cmd.ExecuteNonQuery();
}
}
[CollectionDefinition(nameof(SchemaComplianceCollection))]
public sealed class SchemaComplianceCollection : ICollectionFixture<SchemaComplianceFixture>
{
}

View File

@@ -0,0 +1,172 @@
using Microsoft.Data.SqlClient;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// Introspects the applied schema via <c>sys.*</c> / <c>INFORMATION_SCHEMA.*</c> to confirm that
/// the Fluent-API DbContext produces the exact structure specified in
/// <c>docs/v2/config-db-schema.md</c>. Any change here is a deliberate decision — update the
/// schema doc first, then these tests.
/// </summary>
[Trait("Category", "SchemaCompliance")]
[Collection(nameof(SchemaComplianceCollection))]
public sealed class SchemaComplianceTests
{
private readonly SchemaComplianceFixture _fixture;
public SchemaComplianceTests(SchemaComplianceFixture fixture) => _fixture = fixture;
[Fact]
public void All_expected_tables_exist()
{
var expected = new[]
{
"ServerCluster", "ClusterNode", "ClusterNodeCredential", "ClusterNodeGenerationState",
"ConfigGeneration", "ConfigAuditLog",
"Namespace", "UnsArea", "UnsLine",
"DriverInstance", "Device", "Equipment", "Tag", "PollGroup",
"NodeAcl", "ExternalIdReservation",
};
var actual = QueryStrings(@"
SELECT name FROM sys.tables WHERE name <> '__EFMigrationsHistory' ORDER BY name;").ToHashSet();
foreach (var table in expected)
actual.ShouldContain(table, $"missing table: {table}");
actual.Count.ShouldBe(expected.Length);
}
[Fact]
public void Filtered_unique_indexes_match_schema_spec()
{
// (IndexName, Filter, Uniqueness) tuples — from OtOpcUaConfigDbContext Fluent config.
// Kept here as a spec-level source of truth; the test ensures EF generated them verbatim.
var expected = new[]
{
("UX_ClusterNode_Primary_Per_Cluster", "([RedundancyRole]='Primary')"),
("UX_ClusterNodeCredential_Value", "([Enabled]=(1))"),
("UX_ConfigGeneration_Draft_Per_Cluster", "([Status]='Draft')"),
("UX_ExternalIdReservation_KindValue_Active", "([ReleasedAt] IS NULL)"),
};
var rows = QueryRows(@"
SELECT i.name AS IndexName, i.filter_definition
FROM sys.indexes i
WHERE i.is_unique = 1 AND i.has_filter = 1;",
r => (Name: r.GetString(0), Filter: r.IsDBNull(1) ? null : r.GetString(1)));
foreach (var (name, filter) in expected)
{
var match = rows.FirstOrDefault(x => x.Name == name);
match.Name.ShouldBe(name, $"missing filtered unique index: {name}");
NormalizeFilter(match.Filter).ShouldBe(NormalizeFilter(filter),
$"filter predicate for {name} drifted");
}
}
[Fact]
public void Check_constraints_match_schema_spec()
{
var expected = new[]
{
"CK_ServerCluster_RedundancyMode_NodeCount",
"CK_Device_DeviceConfig_IsJson",
"CK_DriverInstance_DriverConfig_IsJson",
"CK_PollGroup_IntervalMs_Min",
"CK_Tag_TagConfig_IsJson",
"CK_ConfigAuditLog_DetailsJson_IsJson",
};
var actual = QueryStrings("SELECT name FROM sys.check_constraints ORDER BY name;").ToHashSet();
foreach (var ck in expected)
actual.ShouldContain(ck, $"missing CHECK constraint: {ck}");
}
[Fact]
public void Json_check_constraints_use_IsJson_function()
{
var rows = QueryRows(@"
SELECT cc.name, cc.definition
FROM sys.check_constraints cc
WHERE cc.name LIKE 'CK_%_IsJson';",
r => (Name: r.GetString(0), Definition: r.GetString(1)));
rows.Count.ShouldBeGreaterThanOrEqualTo(4);
foreach (var (name, definition) in rows)
definition.ShouldContain("isjson(", Case.Insensitive,
$"{name} definition does not call ISJSON: {definition}");
}
[Fact]
public void ConfigGeneration_Status_uses_nvarchar_enum_storage()
{
var rows = QueryRows(@"
SELECT c.COLUMN_NAME, c.DATA_TYPE, c.CHARACTER_MAXIMUM_LENGTH
FROM INFORMATION_SCHEMA.COLUMNS c
WHERE c.TABLE_NAME = 'ConfigGeneration' AND c.COLUMN_NAME = 'Status';",
r => (Column: r.GetString(0), Type: r.GetString(1), Length: r.IsDBNull(2) ? (int?)null : r.GetInt32(2)));
rows.Count.ShouldBe(1);
rows[0].Type.ShouldBe("nvarchar");
rows[0].Length.ShouldNotBeNull();
}
[Fact]
public void Equipment_carries_Opc40010_identity_fields()
{
var columns = QueryStrings(@"
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'Equipment';")
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var col in new[]
{
"EquipmentUuid", "EquipmentId", "MachineCode", "ZTag", "SAPID",
"Manufacturer", "Model", "SerialNumber",
})
columns.ShouldContain(col, $"Equipment missing expected column: {col}");
}
[Fact]
public void Namespace_has_same_cluster_invariant_index()
{
// Decision #122: namespace logical IDs unique within a cluster + generation. The composite
// unique index enforces that trust boundary.
var indexes = QueryStrings(@"
SELECT i.name
FROM sys.indexes i
JOIN sys.tables t ON i.object_id = t.object_id
WHERE t.name = 'Namespace' AND i.is_unique = 1;").ToList();
indexes.ShouldContain("UX_Namespace_Generation_LogicalId_Cluster");
}
private List<string> QueryStrings(string sql)
{
using var conn = _fixture.OpenConnection();
using var cmd = new SqlCommand(sql, conn);
using var reader = cmd.ExecuteReader();
var result = new List<string>();
while (reader.Read())
result.Add(reader.GetString(0));
return result;
}
private List<T> QueryRows<T>(string sql, Func<SqlDataReader, T> project)
{
using var conn = _fixture.OpenConnection();
using var cmd = new SqlCommand(sql, conn);
using var reader = cmd.ExecuteReader();
var result = new List<T>();
while (reader.Read())
result.Add(project(reader));
return result;
}
private static string? NormalizeFilter(string? filter) =>
filter?.Replace(" ", string.Empty).Replace("(", string.Empty).Replace(")", string.Empty).ToLowerInvariant();
}

View File

@@ -0,0 +1,222 @@
using Microsoft.Data.SqlClient;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// Happy-path + representative error-path coverage per Task B.2 acceptance. Each test seeds its
/// own cluster + node + credential, creates a draft, exercises one proc, then cleans up at the
/// fixture level (the fixture drops the DB in Dispose).
/// </summary>
[Trait("Category", "StoredProcedures")]
[Collection(nameof(SchemaComplianceCollection))]
public sealed class StoredProceduresTests
{
private readonly SchemaComplianceFixture _fixture;
public StoredProceduresTests(SchemaComplianceFixture fixture) => _fixture = fixture;
[Fact]
public void Publish_then_GetCurrent_returns_the_published_generation()
{
using var conn = _fixture.OpenConnection();
var (clusterId, nodeId, _, draftId) = SeedClusterWithDraft(conn, suffix: "pub1");
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
("c", clusterId), ("g", draftId));
using var cmd = conn.CreateCommand();
cmd.CommandText = "EXEC dbo.sp_GetCurrentGenerationForCluster @NodeId=@n, @ClusterId=@c";
cmd.Parameters.AddWithValue("n", nodeId);
cmd.Parameters.AddWithValue("c", clusterId);
using var r = cmd.ExecuteReader();
r.Read().ShouldBeTrue("proc should return exactly one row");
r.GetInt64(0).ShouldBe(draftId);
r.GetString(2).ShouldBe("Published");
}
[Fact]
public void GetCurrent_rejects_caller_not_bound_to_node()
{
using var conn = _fixture.OpenConnection();
var (clusterId, _, _, _) = SeedClusterWithDraft(conn, suffix: "unauth");
var ex = Should.Throw<SqlException>(() =>
Exec(conn, "EXEC dbo.sp_GetCurrentGenerationForCluster @NodeId=@n, @ClusterId=@c",
("n", "ghost-node"), ("c", clusterId)));
ex.Message.ShouldContain("Unauthorized");
}
[Fact]
public void Publish_second_draft_supersedes_first()
{
using var conn = _fixture.OpenConnection();
var (clusterId, _, _, draft1) = SeedClusterWithDraft(conn, suffix: "sup");
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
("c", clusterId), ("g", draft1));
var draft2 = CreateDraft(conn, clusterId);
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
("c", clusterId), ("g", draft2));
var status1 = Scalar<string>(conn,
"SELECT Status FROM dbo.ConfigGeneration WHERE GenerationId = @g", ("g", draft1));
var status2 = Scalar<string>(conn,
"SELECT Status FROM dbo.ConfigGeneration WHERE GenerationId = @g", ("g", draft2));
status1.ShouldBe("Superseded");
status2.ShouldBe("Published");
}
[Fact]
public void Publish_rejects_non_draft_generation()
{
using var conn = _fixture.OpenConnection();
var (clusterId, _, _, draftId) = SeedClusterWithDraft(conn, suffix: "twice");
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
("c", clusterId), ("g", draftId));
var ex = Should.Throw<SqlException>(() =>
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
("c", clusterId), ("g", draftId)));
ex.Message.ShouldContain("not in Draft");
}
[Fact]
public void ValidateDraft_rejects_orphan_tag()
{
using var conn = _fixture.OpenConnection();
var (clusterId, _, _, draftId) = SeedClusterWithDraft(conn, suffix: "orphan");
Exec(conn, @"INSERT dbo.Tag (GenerationId, TagId, DriverInstanceId, Name, DataType, AccessLevel, WriteIdempotent, TagConfig)
VALUES (@g, 'tag-1', 'missing-driver', 'X', 'Int32', 'Read', 0, '{}')",
("g", draftId));
var ex = Should.Throw<SqlException>(() =>
Exec(conn, "EXEC dbo.sp_ValidateDraft @DraftGenerationId=@g", ("g", draftId)));
ex.Message.ShouldContain("unresolved DriverInstanceId");
}
[Fact]
public void Rollback_creates_new_published_generation_and_clones_rows()
{
using var conn = _fixture.OpenConnection();
var (clusterId, _, _, draftId) = SeedClusterWithDraft(conn, suffix: "rb");
SeedMinimalDriverRow(conn, draftId, clusterId, driverInstanceId: "drv-a");
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
("c", clusterId), ("g", draftId));
Exec(conn, "EXEC dbo.sp_RollbackToGeneration @ClusterId=@c, @TargetGenerationId=@g, @Notes='test'",
("c", clusterId), ("g", draftId));
var newlyPublishedCount = Scalar<int>(conn,
@"SELECT COUNT(*) FROM dbo.ConfigGeneration
WHERE ClusterId = @c AND Status = 'Published' AND GenerationId <> @g",
("c", clusterId), ("g", draftId));
newlyPublishedCount.ShouldBe(1);
var driverClonedCount = Scalar<int>(conn,
@"SELECT COUNT(*) FROM dbo.DriverInstance di
JOIN dbo.ConfigGeneration cg ON cg.GenerationId = di.GenerationId
WHERE cg.ClusterId = @c AND cg.Status = 'Published' AND di.DriverInstanceId = 'drv-a'",
("c", clusterId));
driverClonedCount.ShouldBe(1);
}
[Fact]
public void ComputeDiff_returns_Added_for_driver_present_only_in_target()
{
using var conn = _fixture.OpenConnection();
var (clusterId, _, _, draft1) = SeedClusterWithDraft(conn, suffix: "diff");
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
("c", clusterId), ("g", draft1));
var draft2 = CreateDraft(conn, clusterId);
SeedMinimalDriverRow(conn, draft2, clusterId, driverInstanceId: "drv-added");
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
("c", clusterId), ("g", draft2));
using var cmd = conn.CreateCommand();
cmd.CommandText = "EXEC dbo.sp_ComputeGenerationDiff @FromGenerationId=@f, @ToGenerationId=@t";
cmd.Parameters.AddWithValue("f", draft1);
cmd.Parameters.AddWithValue("t", draft2);
using var r = cmd.ExecuteReader();
var diffs = new List<(string Table, string Id, string Kind)>();
while (r.Read())
diffs.Add((r.GetString(0), r.GetString(1), r.GetString(2)));
diffs.ShouldContain(d => d.Table == "DriverInstance" && d.Id == "drv-added" && d.Kind == "Added");
}
[Fact]
public void ReleaseReservation_requires_nonempty_reason()
{
using var conn = _fixture.OpenConnection();
var ex = Should.Throw<SqlException>(() =>
Exec(conn, "EXEC dbo.sp_ReleaseExternalIdReservation @Kind='ZTag', @Value='X', @ReleaseReason=''"));
ex.Message.ShouldContain("ReleaseReason is required");
}
// ---- helpers ----
/// <summary>Creates a cluster, one node, one credential bound to the current SUSER_SNAME(), and an empty Draft.</summary>
private static (string ClusterId, string NodeId, string Credential, long DraftId)
SeedClusterWithDraft(SqlConnection conn, string suffix)
{
var clusterId = $"cluster-{suffix}";
var nodeId = $"node-{suffix}-a";
// Every test uses the same SUSER_SNAME() ('sa' by default), and the credential unique index
// is filtered on Enabled=1 across (Kind, Value) globally. To avoid collisions across tests
// sharing one DB, we disable old credentials first.
Exec(conn, "UPDATE dbo.ClusterNodeCredential SET Enabled = 0 WHERE Value = SUSER_SNAME();");
Exec(conn,
@"INSERT dbo.ServerCluster (ClusterId, Name, Enterprise, Site, RedundancyMode, NodeCount, Enabled, CreatedBy)
VALUES (@c, @c, 'zb', @s, 'None', 1, 1, SUSER_SNAME());
INSERT dbo.ClusterNode (NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES (@n, @c, 'Primary', 'localhost', 4840, 5001, CONCAT('urn:localhost:', @s), 200, 1, SUSER_SNAME());
INSERT dbo.ClusterNodeCredential (NodeId, Kind, Value, Enabled, CreatedBy)
VALUES (@n, 'SqlLogin', SUSER_SNAME(), 1, SUSER_SNAME());",
("c", clusterId), ("n", nodeId), ("s", suffix));
var draftId = CreateDraft(conn, clusterId);
return (clusterId, nodeId, "sa", draftId);
}
private static long CreateDraft(SqlConnection conn, string clusterId)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
INSERT dbo.ConfigGeneration (ClusterId, Status, CreatedAt, CreatedBy)
VALUES (@c, 'Draft', SYSUTCDATETIME(), SUSER_SNAME());
SELECT CAST(SCOPE_IDENTITY() AS bigint);";
cmd.Parameters.AddWithValue("c", clusterId);
return (long)cmd.ExecuteScalar()!;
}
private static void SeedMinimalDriverRow(SqlConnection conn, long genId, string clusterId, string driverInstanceId)
{
Exec(conn,
@"INSERT dbo.Namespace (GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
VALUES (@g, @ns, @c, 'Equipment', 'urn:ns', 1);
INSERT dbo.DriverInstance (GenerationId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig)
VALUES (@g, @drv, @c, @ns, 'drv', 'ModbusTcp', 1, '{}');",
("g", genId), ("c", clusterId), ("ns", $"ns-{driverInstanceId}"), ("drv", driverInstanceId));
}
private static void Exec(SqlConnection conn, string sql, params (string Name, object Value)[] parameters)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
foreach (var (name, value) in parameters) cmd.Parameters.AddWithValue(name, value);
cmd.ExecuteNonQuery();
}
private static T Scalar<T>(SqlConnection conn, string sql, params (string Name, object Value)[] parameters)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
foreach (var (name, value) in parameters) cmd.Parameters.AddWithValue(name, value);
return (T)cmd.ExecuteScalar()!;
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Configuration.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.1"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,80 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests;
[Trait("Category", "Unit")]
public sealed class DriverHostTests
{
private sealed class StubDriver(string id, bool failInit = false) : IDriver
{
public string DriverInstanceId { get; } = id;
public string DriverType => "Stub";
public bool Initialized { get; private set; }
public bool ShutDown { get; private set; }
public Task InitializeAsync(string _, CancellationToken ct)
{
if (failInit) throw new InvalidOperationException("boom");
Initialized = true;
return Task.CompletedTask;
}
public Task ReinitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken ct) { ShutDown = true; return Task.CompletedTask; }
public DriverHealth GetHealth() =>
new(Initialized ? DriverState.Healthy : DriverState.Unknown, null, null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
}
[Fact]
public async Task Register_initializes_driver_and_tracks_health()
{
await using var host = new DriverHost();
var driver = new StubDriver("d-1");
await host.RegisterAsync(driver, "{}", CancellationToken.None);
host.RegisteredDriverIds.ShouldContain("d-1");
driver.Initialized.ShouldBeTrue();
host.GetHealth("d-1")!.State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task Register_rethrows_init_failure_but_keeps_driver_registered()
{
await using var host = new DriverHost();
var driver = new StubDriver("d-bad", failInit: true);
await Should.ThrowAsync<InvalidOperationException>(() =>
host.RegisterAsync(driver, "{}", CancellationToken.None));
host.RegisteredDriverIds.ShouldContain("d-bad");
}
[Fact]
public async Task Duplicate_registration_throws()
{
await using var host = new DriverHost();
await host.RegisterAsync(new StubDriver("d-1"), "{}", CancellationToken.None);
await Should.ThrowAsync<InvalidOperationException>(() =>
host.RegisterAsync(new StubDriver("d-1"), "{}", CancellationToken.None));
}
[Fact]
public async Task Unregister_shuts_down_and_removes()
{
await using var host = new DriverHost();
var driver = new StubDriver("d-1");
await host.RegisterAsync(driver, "{}", CancellationToken.None);
await host.UnregisterAsync("d-1", CancellationToken.None);
host.RegisteredDriverIds.ShouldNotContain("d-1");
driver.ShutDown.ShouldBeTrue();
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,64 @@
using System;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
[Trait("Category", "Unit")]
public sealed class MemoryWatchdogTests
{
private const long Mb = 1024 * 1024;
[Fact]
public void Baseline_sample_returns_None()
{
var w = new MemoryWatchdog(baselineBytes: 300 * Mb);
w.Sample(320 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.None);
}
[Fact]
public void Warn_threshold_uses_larger_of_1_5x_or_plus_200MB()
{
// Baseline 300 → warn threshold = max(450, 500) = 500 MB
var w = new MemoryWatchdog(baselineBytes: 300 * Mb);
w.Sample(499 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.None);
w.Sample(500 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.Warn);
}
[Fact]
public void Soft_recycle_triggers_at_2x_or_plus_200MB_whichever_larger()
{
// Baseline 400 → soft = max(800, 600) = 800 MB
var w = new MemoryWatchdog(baselineBytes: 400 * Mb);
w.Sample(799 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.Warn);
w.Sample(800 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.SoftRecycle);
}
[Fact]
public void Hard_kill_triggers_at_absolute_ceiling()
{
var w = new MemoryWatchdog(baselineBytes: 1000 * Mb);
w.Sample(1501 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.HardKill);
}
[Fact]
public void Sustained_slope_triggers_soft_recycle_before_absolute_threshold()
{
// Baseline 1000 MB → warn = 1200, soft = 2000 (absolute). Slope 6 MB/min over 30 min = 180 MB
// delta — still well below the absolute soft threshold; slope detector must fire on its own.
var w = new MemoryWatchdog(baselineBytes: 1000 * Mb) { SustainedSlopeBytesPerMinute = 5 * Mb };
var t0 = new DateTime(2026, 4, 17, 12, 0, 0, DateTimeKind.Utc);
long rss = 1050 * Mb;
var slopeFired = false;
for (var i = 0; i <= 35; i++)
{
var action = w.Sample(rss, t0.AddMinutes(i));
if (action == WatchdogAction.SoftRecycle) { slopeFired = true; break; }
rss += 6 * Mb;
}
slopeFired.ShouldBeTrue("slope detector should fire once the 30-min window fills");
}
}

View File

@@ -0,0 +1,64 @@
using System;
using System.IO;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
[Trait("Category", "Unit")]
public sealed class PostMortemMmfTests : IDisposable
{
private readonly string _path = Path.Combine(Path.GetTempPath(), $"mmf-test-{Guid.NewGuid():N}.bin");
public void Dispose()
{
if (File.Exists(_path)) File.Delete(_path);
}
[Fact]
public void Write_then_read_round_trips_entries_in_oldest_first_order()
{
using (var mmf = new PostMortemMmf(_path, capacity: 10))
{
mmf.Write(0x30, "read tag-1");
mmf.Write(0x30, "read tag-2");
mmf.Write(0x32, "write tag-3");
}
using var reopen = new PostMortemMmf(_path, capacity: 10);
var entries = reopen.ReadAll();
entries.Length.ShouldBe(3);
entries[0].Message.ShouldBe("read tag-1");
entries[1].Message.ShouldBe("read tag-2");
entries[2].Message.ShouldBe("write tag-3");
entries[0].OpKind.ShouldBe(0x30L);
}
[Fact]
public void Ring_buffer_wraps_and_oldest_entry_is_overwritten()
{
using var mmf = new PostMortemMmf(_path, capacity: 3);
mmf.Write(1, "A");
mmf.Write(2, "B");
mmf.Write(3, "C");
mmf.Write(4, "D"); // overwrites A
var entries = mmf.ReadAll();
entries.Length.ShouldBe(3);
entries[0].Message.ShouldBe("B");
entries[1].Message.ShouldBe("C");
entries[2].Message.ShouldBe("D");
}
[Fact]
public void Message_longer_than_capacity_is_truncated_safely()
{
using var mmf = new PostMortemMmf(_path, capacity: 2);
var huge = new string('x', 500);
mmf.Write(0, huge);
var entries = mmf.ReadAll();
entries[0].Message.Length.ShouldBeLessThan(PostMortemMmf.EntryBytes);
}
}

View File

@@ -0,0 +1,51 @@
using System;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
[Trait("Category", "Unit")]
public sealed class RecyclePolicyTests
{
[Fact]
public void First_soft_recycle_is_allowed()
{
var p = new RecyclePolicy();
p.TryRequestSoftRecycle(DateTime.UtcNow, out var reason).ShouldBeTrue();
reason.ShouldBeNull();
}
[Fact]
public void Second_soft_recycle_within_cap_is_blocked()
{
var p = new RecyclePolicy();
var t0 = DateTime.UtcNow;
p.TryRequestSoftRecycle(t0, out _).ShouldBeTrue();
p.TryRequestSoftRecycle(t0.AddMinutes(30), out var reason).ShouldBeFalse();
reason.ShouldContain("frequency cap");
}
[Fact]
public void Recycle_after_cap_elapses_is_allowed_again()
{
var p = new RecyclePolicy();
var t0 = DateTime.UtcNow;
p.TryRequestSoftRecycle(t0, out _).ShouldBeTrue();
p.TryRequestSoftRecycle(t0.AddHours(1).AddMinutes(1), out _).ShouldBeTrue();
}
[Fact]
public void Scheduled_recycle_fires_once_per_day_at_local_3am()
{
var p = new RecyclePolicy();
var last = DateTime.MinValue;
p.ShouldSoftRecycleScheduled(new DateTime(2026, 4, 17, 2, 59, 0), ref last).ShouldBeFalse();
p.ShouldSoftRecycleScheduled(new DateTime(2026, 4, 17, 3, 0, 0), ref last).ShouldBeTrue();
p.ShouldSoftRecycleScheduled(new DateTime(2026, 4, 17, 3, 30, 0), ref last).ShouldBeFalse(
"already fired today");
p.ShouldSoftRecycleScheduled(new DateTime(2026, 4, 18, 3, 0, 0), ref last).ShouldBeTrue(
"next day fires again");
}
}

View File

@@ -0,0 +1,47 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
[Trait("Category", "Unit")]
public sealed class StaPumpTests
{
[Fact]
public async Task InvokeAsync_runs_work_on_the_STA_thread()
{
using var pump = new StaPump();
await pump.WaitForStartedAsync();
var apartment = await pump.InvokeAsync(() => Thread.CurrentThread.GetApartmentState());
apartment.ShouldBe(ApartmentState.STA);
}
[Fact]
public async Task Responsiveness_probe_returns_true_under_healthy_pump()
{
using var pump = new StaPump();
await pump.WaitForStartedAsync();
(await pump.IsResponsiveAsync(TimeSpan.FromSeconds(2))).ShouldBeTrue();
}
[Fact]
public async Task Responsiveness_probe_returns_false_when_pump_is_wedged()
{
using var pump = new StaPump();
await pump.WaitForStartedAsync();
// Wedge the pump with an infinite work item on the STA thread.
var wedge = new ManualResetEventSlim();
_ = pump.InvokeAsync(() => wedge.Wait());
var responsive = await pump.IsResponsiveAsync(TimeSpan.FromMilliseconds(500));
responsive.ShouldBeFalse();
wedge.Set();
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit" Version="2.9.2"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,28 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests;
[Trait("Category", "Unit")]
public sealed class BackoffTests
{
[Fact]
public void Default_sequence_is_5_15_60_seconds_capped()
{
var b = new Backoff();
b.Next().ShouldBe(TimeSpan.FromSeconds(5));
b.Next().ShouldBe(TimeSpan.FromSeconds(15));
b.Next().ShouldBe(TimeSpan.FromSeconds(60));
b.Next().ShouldBe(TimeSpan.FromSeconds(60), "capped once past the last entry");
}
[Fact]
public void RecordStableRun_resets_to_the_first_delay()
{
var b = new Backoff();
b.Next(); b.Next();
b.RecordStableRun();
b.Next().ShouldBe(TimeSpan.FromSeconds(5));
}
}

View File

@@ -0,0 +1,78 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests;
[Trait("Category", "Unit")]
public sealed class CircuitBreakerTests
{
[Fact]
public void First_three_crashes_within_window_allow_respawn()
{
var breaker = new CircuitBreaker();
var t0 = new DateTime(2026, 4, 17, 12, 0, 0, DateTimeKind.Utc);
breaker.TryRecordCrash(t0, out _).ShouldBeTrue();
breaker.TryRecordCrash(t0.AddSeconds(30), out _).ShouldBeTrue();
breaker.TryRecordCrash(t0.AddSeconds(60), out _).ShouldBeTrue();
}
[Fact]
public void Fourth_crash_within_window_opens_breaker_with_sticky_alert()
{
var breaker = new CircuitBreaker();
var t0 = new DateTime(2026, 4, 17, 12, 0, 0, DateTimeKind.Utc);
for (var i = 0; i < 3; i++) breaker.TryRecordCrash(t0.AddSeconds(i * 30), out _);
breaker.TryRecordCrash(t0.AddSeconds(120), out var remaining).ShouldBeFalse();
remaining.ShouldBe(TimeSpan.FromHours(1));
breaker.StickyAlertActive.ShouldBeTrue();
}
[Fact]
public void Cooldown_escalates_1h_then_4h_then_manual()
{
var breaker = new CircuitBreaker();
var t0 = new DateTime(2026, 4, 17, 12, 0, 0, DateTimeKind.Utc);
// Open once.
for (var i = 0; i < 4; i++) breaker.TryRecordCrash(t0.AddSeconds(i * 30), out _);
// Cooldown starts when the breaker opens (the 4th crash, at t0+90s). Jump past 1h from there.
var openedAt = t0.AddSeconds(90);
var afterFirstCooldown = openedAt.AddHours(1).AddMinutes(1);
breaker.TryRecordCrash(afterFirstCooldown, out _).ShouldBeTrue("cooldown elapsed, breaker closes for a try");
// Second trip: within 5 min, breaker opens again with 4h cooldown. The crash that trips
// it is the 3rd retry since the cooldown closed (afterFirstCooldown itself counted as 1).
breaker.TryRecordCrash(afterFirstCooldown.AddSeconds(30), out _).ShouldBeTrue();
breaker.TryRecordCrash(afterFirstCooldown.AddSeconds(60), out _).ShouldBeTrue();
breaker.TryRecordCrash(afterFirstCooldown.AddSeconds(90), out var cd2).ShouldBeFalse(
"4th crash within window reopens the breaker");
cd2.ShouldBe(TimeSpan.FromHours(4));
// Third trip: 4h elapsed, breaker closes for a try, then reopens with MaxValue (manual only).
var reopenedAt = afterFirstCooldown.AddSeconds(90);
var afterSecondCooldown = reopenedAt.AddHours(4).AddMinutes(1);
breaker.TryRecordCrash(afterSecondCooldown, out _).ShouldBeTrue();
breaker.TryRecordCrash(afterSecondCooldown.AddSeconds(30), out _).ShouldBeTrue();
breaker.TryRecordCrash(afterSecondCooldown.AddSeconds(60), out _).ShouldBeTrue();
breaker.TryRecordCrash(afterSecondCooldown.AddSeconds(90), out var cd3).ShouldBeFalse();
cd3.ShouldBe(TimeSpan.MaxValue);
}
[Fact]
public void ManualReset_clears_sticky_alert_and_crash_history()
{
var breaker = new CircuitBreaker();
var t0 = DateTime.UtcNow;
for (var i = 0; i < 4; i++) breaker.TryRecordCrash(t0.AddSeconds(i * 30), out _);
breaker.ManualReset();
breaker.StickyAlertActive.ShouldBeFalse();
breaker.TryRecordCrash(t0.AddMinutes(10), out _).ShouldBeTrue();
}
}

View File

@@ -0,0 +1,40 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests;
[Trait("Category", "Unit")]
public sealed class HeartbeatMonitorTests
{
[Fact]
public void Single_miss_does_not_declare_dead()
{
var m = new HeartbeatMonitor();
m.RecordMiss().ShouldBeFalse();
m.RecordMiss().ShouldBeFalse();
}
[Fact]
public void Three_consecutive_misses_declare_host_dead()
{
var m = new HeartbeatMonitor();
m.RecordMiss().ShouldBeFalse();
m.RecordMiss().ShouldBeFalse();
m.RecordMiss().ShouldBeTrue();
}
[Fact]
public void Ack_resets_the_miss_counter()
{
var m = new HeartbeatMonitor();
m.RecordMiss();
m.RecordMiss();
m.RecordAck(DateTime.UtcNow);
m.ConsecutiveMisses.ShouldBe(0);
m.RecordMiss().ShouldBeFalse();
m.RecordMiss().ShouldBeFalse();
}
}

View File

@@ -0,0 +1,91 @@
using System.IO.Pipes;
using System.Security.Principal;
using Serilog;
using Serilog.Core;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests;
/// <summary>
/// End-to-end IPC test: <see cref="PipeServer"/> (from Galaxy.Host) accepts a connection from
/// the Proxy's <see cref="GalaxyIpcClient"/>. Verifies the Hello handshake, shared-secret
/// check, and heartbeat round-trip. Uses the current user's SID so the ACL allows the
/// localhost test process. Skipped on non-Windows (pipe ACL is Windows-only).
/// </summary>
[Trait("Category", "Integration")]
public sealed class IpcHandshakeIntegrationTests
{
[Fact]
public async Task Hello_handshake_with_correct_secret_succeeds_and_heartbeat_round_trips()
{
if (!OperatingSystem.IsWindows()) return; // pipe ACL is Windows-only
if (IsAdministrator()) return; // ACL explicitly denies Administrators — skip on admin shells
using var currentIdentity = WindowsIdentity.GetCurrent();
var allowedSid = currentIdentity.User!;
var pipeName = $"OtOpcUaGalaxyTest-{Guid.NewGuid():N}";
const string secret = "test-secret-2026";
Logger log = new LoggerConfiguration().CreateLogger();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var server = new PipeServer(pipeName, allowedSid, secret, log);
var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token));
await using var client = await GalaxyIpcClient.ConnectAsync(
pipeName, secret, TimeSpan.FromSeconds(5), cts.Token);
// Heartbeat round-trip via the stub handler.
var ack = await client.CallAsync<Heartbeat, HeartbeatAck>(
MessageKind.Heartbeat,
new Heartbeat { SequenceNumber = 42, UtcUnixMs = 1000 },
MessageKind.HeartbeatAck,
cts.Token);
ack.SequenceNumber.ShouldBe(42L);
cts.Cancel();
try { await serverTask; } catch (OperationCanceledException) { }
server.Dispose();
}
[Fact]
public async Task Hello_with_wrong_secret_is_rejected()
{
if (!OperatingSystem.IsWindows()) return;
if (IsAdministrator()) return;
using var currentIdentity = WindowsIdentity.GetCurrent();
var allowedSid = currentIdentity.User!;
var pipeName = $"OtOpcUaGalaxyTest-{Guid.NewGuid():N}";
Logger log = new LoggerConfiguration().CreateLogger();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var server = new PipeServer(pipeName, allowedSid, "real-secret", log);
var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token));
await Should.ThrowAsync<UnauthorizedAccessException>(() =>
GalaxyIpcClient.ConnectAsync(pipeName, "wrong-secret", TimeSpan.FromSeconds(5), cts.Token));
cts.Cancel();
try { await serverTask; } catch { /* server loop ends */ }
server.Dispose();
}
/// <summary>
/// The production ACL explicitly denies Administrators. On dev boxes the interactive user
/// is often an Administrator, so the allow rule gets overridden by the deny — the pipe
/// refuses the connection. Skip in that case; the production install runs as a dedicated
/// non-admin service account.
/// </summary>
private static bool IsAdministrator()
{
if (!OperatingSystem.IsWindows()) return false;
using var identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);
return principal.IsInRole(WindowsBuiltInRole.Administrator);
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,68 @@
using System.Reflection;
using MessagePack;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests;
[Trait("Category", "Unit")]
public sealed class ContractRoundTripTests
{
/// <summary>
/// Every MessagePack contract in the Shared project must round-trip. Byte-for-byte equality
/// on re-serialization proves the contract is deterministic — critical for the Hello
/// version-negotiation hash and for debugging wire dumps.
/// </summary>
[Fact]
public void All_MessagePackObject_contracts_round_trip_byte_for_byte()
{
var contractTypes = typeof(Hello).Assembly.GetTypes()
.Where(t => t.GetCustomAttribute<MessagePackObjectAttribute>() is not null)
.ToList();
contractTypes.Count.ShouldBeGreaterThan(15, "scan should find all contracts");
foreach (var type in contractTypes)
{
var instance = Activator.CreateInstance(type);
var bytes1 = MessagePackSerializer.Serialize(type, instance);
var hydrated = MessagePackSerializer.Deserialize(type, bytes1);
var bytes2 = MessagePackSerializer.Serialize(type, hydrated);
bytes2.ShouldBe(bytes1, $"{type.Name} did not round-trip byte-for-byte");
}
}
[Fact]
public void Hello_default_reports_current_protocol_version()
{
var h = new Hello { PeerName = "Proxy", SharedSecret = "x" };
h.ProtocolMajor.ShouldBe(Hello.CurrentMajor);
h.ProtocolMinor.ShouldBe(Hello.CurrentMinor);
}
[Fact]
public void OpenSessionRequest_round_trips_values()
{
var req = new OpenSessionRequest { DriverInstanceId = "gal-1", DriverConfigJson = "{\"x\":1}" };
var bytes = MessagePackSerializer.Serialize(req);
var hydrated = MessagePackSerializer.Deserialize<OpenSessionRequest>(bytes);
hydrated.DriverInstanceId.ShouldBe("gal-1");
hydrated.DriverConfigJson.ShouldBe("{\"x\":1}");
}
[Fact]
public void Contracts_reference_only_BCL_and_MessagePack()
{
var asm = typeof(Hello).Assembly;
var references = asm.GetReferencedAssemblies()
.Select(n => n.Name!)
.Where(n => !n.StartsWith("System.") && n != "mscorlib" && n != "netstandard")
.ToList();
// Only MessagePack should appear outside BCL — no System.Text.Json, no EF, no AspNetCore.
references.ShouldAllBe(n => n == "MessagePack" || n == "MessagePack.Annotations");
}
}

View File

@@ -0,0 +1,74 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests;
[Trait("Category", "Unit")]
public sealed class FramingTests
{
[Fact]
public async Task FrameWriter_FrameReader_round_trip_preserves_kind_and_body()
{
using var ms = new MemoryStream();
using (var writer = new FrameWriter(ms, leaveOpen: true))
{
await writer.WriteAsync(MessageKind.Hello,
new Hello { PeerName = "p", SharedSecret = "s" }, TestContext.Current.CancellationToken);
await writer.WriteAsync(MessageKind.Heartbeat,
new Heartbeat { SequenceNumber = 7, UtcUnixMs = 42 }, TestContext.Current.CancellationToken);
}
ms.Position = 0;
using var reader = new FrameReader(ms, leaveOpen: true);
var f1 = (await reader.ReadFrameAsync(TestContext.Current.CancellationToken))!.Value;
f1.Kind.ShouldBe(MessageKind.Hello);
FrameReader.Deserialize<Hello>(f1.Body).PeerName.ShouldBe("p");
var f2 = (await reader.ReadFrameAsync(TestContext.Current.CancellationToken))!.Value;
f2.Kind.ShouldBe(MessageKind.Heartbeat);
FrameReader.Deserialize<Heartbeat>(f2.Body).SequenceNumber.ShouldBe(7L);
var eof = await reader.ReadFrameAsync(TestContext.Current.CancellationToken);
eof.ShouldBeNull();
}
[Fact]
public async Task FrameReader_rejects_frames_larger_than_the_cap()
{
using var ms = new MemoryStream();
var evilLen = Framing.MaxFrameBodyBytes + 1;
ms.Write(new byte[]
{
(byte)((evilLen >> 24) & 0xFF),
(byte)((evilLen >> 16) & 0xFF),
(byte)((evilLen >> 8) & 0xFF),
(byte)( evilLen & 0xFF),
}, 0, 4);
ms.WriteByte((byte)MessageKind.Hello);
ms.Position = 0;
using var reader = new FrameReader(ms, leaveOpen: true);
await Should.ThrowAsync<InvalidDataException>(() =>
reader.ReadFrameAsync(TestContext.Current.CancellationToken).AsTask());
}
private static class TestContext
{
public static TestContextHelper Current { get; } = new();
}
private sealed class TestContextHelper
{
public CancellationToken CancellationToken => CancellationToken.None;
}
}
file static class TaskExtensions
{
public static Task AsTask<T>(this ValueTask<T> vt) => vt.AsTask();
public static Task AsTask<T>(this Task<T> t) => t;
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,63 @@
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
using ZB.MOM.WW.OtOpcUa.Server;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
[Trait("Category", "Unit")]
public sealed class NodeBootstrapTests
{
private sealed class StubCache : ILocalConfigCache
{
public GenerationSnapshot? Stored { get; set; }
public Task<GenerationSnapshot?> GetMostRecentAsync(string _, CancellationToken __) => Task.FromResult(Stored);
public Task PutAsync(GenerationSnapshot _, CancellationToken __) => Task.CompletedTask;
public Task PruneOldGenerationsAsync(string _, int __, CancellationToken ___) => Task.CompletedTask;
}
[Fact]
public async Task Falls_back_to_cache_when_DB_unreachable()
{
var cache = new StubCache
{
Stored = new GenerationSnapshot
{
ClusterId = "c", GenerationId = 42, CachedAt = DateTime.UtcNow, PayloadJson = "{}",
},
};
var bootstrap = new NodeBootstrap(
new NodeOptions
{
NodeId = "n",
ClusterId = "c",
ConfigDbConnectionString = "Server=127.0.0.1,59999;Database=nope;User Id=x;Password=x;TrustServerCertificate=True;Connect Timeout=1;",
},
cache,
NullLogger<NodeBootstrap>.Instance);
var result = await bootstrap.LoadCurrentGenerationAsync(CancellationToken.None);
result.Source.ShouldBe(BootstrapSource.LocalCache);
result.GenerationId.ShouldBe(42);
}
[Fact]
public async Task Throws_BootstrapException_when_DB_unreachable_and_cache_empty()
{
var bootstrap = new NodeBootstrap(
new NodeOptions
{
NodeId = "n",
ClusterId = "c",
ConfigDbConnectionString = "Server=127.0.0.1,59999;Database=nope;User Id=x;Password=x;TrustServerCertificate=True;Connect Timeout=1;",
},
new StubCache(),
NullLogger<NodeBootstrap>.Instance);
await Should.ThrowAsync<BootstrapException>(() =>
bootstrap.LoadCurrentGenerationAsync(CancellationToken.None));
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Server.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Server\ZB.MOM.WW.OtOpcUa.Server.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>