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:
18
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/AdminRolesTests.cs
Normal file
18
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/AdminRolesTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
{
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()!;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
80
tests/ZB.MOM.WW.OtOpcUa.Core.Tests/DriverHostTests.cs
Normal file
80
tests/ZB.MOM.WW.OtOpcUa.Core.Tests/DriverHostTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
63
tests/ZB.MOM.WW.OtOpcUa.Server.Tests/NodeBootstrapTests.cs
Normal file
63
tests/ZB.MOM.WW.OtOpcUa.Server.Tests/NodeBootstrapTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user